74 Commits

Author SHA1 Message Date
Experiments DB Dev cd4e213d6f docs(plan): configurable data export implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 13:14:41 -04:00
Experiments DB Dev 900759074d docs(spec): configurable data export — assignable axes + subject group cell
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 13:09:07 -04:00
Experiments DB Dev 57446d5b64 Merge branch 'feat/experiment-data-export'
Client-side CSV export on the experiment page: pick a daily parameter
(session metrics or any daily-record field) and download a date×subject matrix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:21:07 -04:00
Experiments DB Dev ea1a7f39f2 feat(frontend): Export data button + modal on experiment page 2026-07-06 15:48:02 -04:00
Experiments DB Dev 0bf3fc479b refactor(frontend): polish ExportDataModal (derived selection, safe URL revoke, error escape) 2026-07-06 15:46:52 -04:00
Experiments DB Dev ea76cf9cd2 feat(frontend): ExportDataModal — pick a parameter and download CSV 2026-07-06 15:42:30 -04:00
Experiments DB Dev dfe3938afc feat(frontend): toCSV + csvFilename for data export 2026-07-06 15:40:24 -04:00
Experiments DB Dev b5578f6e24 feat(frontend): buildMatrix pivots statuses into date×subject grid 2026-07-06 15:37:33 -04:00
Experiments DB Dev 3f8febfda3 feat(frontend): listExportableParams enumerates export options 2026-07-06 15:35:19 -04:00
Experiments DB Dev 523cf1614e feat(frontend): extractValue helper for data export 2026-07-06 15:33:30 -04:00
Experiments DB Dev 9b55b578f2 docs: implementation plan for experiment data export (CSV)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 15:30:41 -04:00
Experiments DB Dev 2d3b7c4607 docs: design for experiment data export (CSV)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 15:25:25 -04:00
Experiments DB Dev 740a884efa Merge: persist cross-subject plot config per experiment 2026-06-01 10:34:54 -04:00
Experiments DB Dev b0c6217cb4 fix(backend): widen plot_config size cap to fit a full hiddenSubjects list
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 10:30:06 -04:00
Experiments DB Dev 9a9b5dfae8 feat(frontend): hydrate + debounced-persist cross-subject plot config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 10:26:33 -04:00
Experiments DB Dev 4b82df514f feat(frontend): serialize/read helpers for plot config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 10:18:56 -04:00
Experiments DB Dev e9511bdac3 feat(backend): persist per-experiment plot_config via PUT /:id/plot-config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 10:14:50 -04:00
Experiments DB Dev 2dd4a329ee docs: implementation plan for persisting plot config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 10:10:39 -04:00
Experiments DB Dev 8877747a63 docs: design for persisting cross-subject plot config per experiment
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 10:06:40 -04:00
Experiments DB Dev c768b5829a Merge: cross-subject plot interactions (sidebar, legend toggle/highlight, ordered tooltip) 2026-06-01 09:56:41 -04:00
Experiments DB Dev 839d05f449 feat(frontend): subject sidebar, legend toggle/highlight, ordered tooltip on cross-subject plot
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 09:41:43 -04:00
Experiments DB Dev 7b34c2fa52 feat(frontend): add SubjectSidebar visibility panel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 09:33:17 -04:00
Experiments DB Dev 0f7f6fbbe3 feat(frontend): pure helpers for cross-subject plot interactions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 09:29:21 -04:00
Experiments DB Dev d77b6c1528 docs: implementation plan for cross-subject plot interactions
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 09:26:00 -04:00
Experiments DB Dev 3095628479 docs: design for cross-subject plot interactions
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 08:45:46 -04:00
Experiments DB Dev 1b0f083cc5 feat(frontend): persist bucket config to animal on successful analysis
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 08:05:07 -04:00
Experiments DB Dev 75a343d617 feat(frontend): render numerical-bucket plots in CSV-analysis results
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 08:03:48 -04:00
Experiments DB Dev 39c01c567c feat(frontend): add field picker + numerical bucket UI in classify phase
Replaces "+ New category" with a Note/Numerical type-aware picker, extends
CategoryBucket to render type badges, bulk-add, and custom-bucket remove controls,
and deletes the addCategory() back-compat shim from Task 7.
2026-05-01 08:01:41 -04:00
Experiments DB Dev ef17ee0642 feat(frontend): seed CsvAnalysis buckets from animal config 2026-05-01 07:58:56 -04:00
Experiments DB Dev ceaa3be200 refactor(frontend): unify CsvAnalysis state under buckets[] 2026-05-01 07:56:22 -04:00
Experiments DB Dev b9b8a4ef1d feat(frontend): add computeNumericSeries helper 2026-05-01 07:51:22 -04:00
Experiments DB Dev 0b83795665 feat(frontend): add extractNumber helper 2026-05-01 07:50:56 -04:00
Experiments DB Dev 6a6e6b310a feat(frontend): add animalsApi.updateCsvConfig
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 07:49:41 -04:00
Experiments DB Dev fe4e89f497 feat(backend): PATCH /animals/:id/csv-config endpoint
Implement TDD for new route that accepts CSV configuration with validated buckets.
Route uses auditMiddleware to log config changes and delegates validation to
validateCsvConfig helper from Task 2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 07:49:00 -04:00
Experiments DB Dev 1ae55591f4 feat(backend): add csv_analysis_config validator 2026-05-01 07:47:17 -04:00
Experiments DB Dev 7b023fb8a3 feat(db): add csv_analysis_config JSON column to animals 2026-05-01 07:43:58 -04:00
Experiments DB Dev cdff6732b0 feat: manual metrics editor + past-analyses inline viewer on daily status
- AnalysisSummaryCard gains an "Edit manually" affordance powered by
  the new ManualMetricsForm (add/remove categories, edit counts,
  recompute total + success rate).
- New PastAnalysesViewer renders a tab-style selector of saved CSV
  analyses with an inline AnalysisResultsView panel.
- Reorganizes the page so the manual-metrics path is reachable when no
  analysis_summary exists yet.
- Sync package-lock to the recharts ^2.15.4 already declared in package.json.
2026-05-01 07:40:10 -04:00
Experiments DB Dev 69ce5fbeab docs: add implementation plan for numerical CSV-analysis buckets
12-task TDD plan covering DB backup, schema migration, backend
PATCH endpoint with validator, frontend lib helpers (extractNumber,
computeNumericSeries), CsvAnalysis state refactor, type-aware field
picker, numerical plot rendering, and per-animal config persistence.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 07:36:44 -04:00
Experiments DB Dev 2ed69aa6d6 docs: add design spec for numerical CSV-analysis buckets per animal
Spec for adding a numerical bucket type to CSV analysis that plots
note values (numeric portion) against frame/timestamp, persisted on
the Animal model so subsequent uploads reuse the configuration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 07:30:18 -04:00
Experiments DB Dev ee2cf19b74 test: add analysisOnly regression tests; fix cache isolation in tests
New file tests/experimentDailyStatuses.test.js — 9 tests covering
GET /api/experiments/:id/daily-statuses:
  - 404 on unknown experiment
  - all statuses returned when no filter
  - analysisOnly=true keeps only non-null analysis_summary records
  - analysisOnly=true returns [] when none have analysis_summary
  - the exact null/object mix that caused the production 500 now passes
  - analysisOnly=false (explicit) returns all records
  - ?date= param is forwarded to Prisma correctly
  - empty-array response on zero statuses
  - returned records include expected fields

Fixed tests/calendar.test.js: existing tests were silently broken by the
cache added in the perf commit. Both test files now mock lib/cache to
always miss, so each test exercises the real route logic in isolation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 15:11:21 -04:00
Experiments DB Dev d125d2e365 fix: filter analysis_summary in JS instead of Prisma WHERE clause
Prisma 5 rejects both null and Prisma.DbNull as WHERE filter values for
Json? fields (no clean IS NOT NULL operator). Filter the result array in
JS after the query — the output is immediately cached so this only runs
once per cache period.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:54:08 -04:00
Experiments DB Dev c0d0b8e6ba fix: use Prisma.DbNull to filter non-null Json? field in analysisOnly
Prisma 5 rejects JS null as a where-clause value on Json? fields.
Use Prisma.DbNull (SQL NULL sentinel) so the NOT filter compiles correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:47:50 -04:00
Experiments DB Dev 5c0703c271 feat: per-chart customization — size, Y range, X zoom, independent X/Y data
Each chart card now has a "▾ customize" button. Clicking it reveals a
settings bar (only rendered when active — zero cost when collapsed):

  Size      — S / M / L height selector
  Y range   — min / max inputs to set the YAxis domain
  X zoom    — min / max inputs to clip the displayed X domain
  X data    — per-chart X field override, independent from the shared
              global X selector (each chart can use a different field)
  Y lines   — (counts chart only) toggle individual category lines on/off
  ↺ Reset   — shown only when any setting has been changed from default

The global X-axis selector and tick-step control remain at the component
level as the persistent defaults. Per-chart overrides are session-only
(non-persistent), resetting on page load.

Performance: animalRows computation in ExperimentAnalysisCharts is now
split from the per-chart data step, so a metric/X change only recomputes
the affected chart. Per-chart data only recomputes when that chart's
xFieldOverride changes; shared data is reused otherwise.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:36:19 -04:00
Experiments DB Dev d2c8d69718 perf: server-side caching + remove bulk status fetch from experiment page
- Add backend/src/lib/cache.js — in-process TTL Map cache with
  prefix-based invalidation; no external dependencies

- Cache /experiments/:id/calendar?field=X per (experimentId, field).
  Invalidated immediately on any DailyStatus write.

- Cache /experiments/:id/daily-statuses per (experimentId, date, analysisOnly).
  Invalidated immediately on any DailyStatus write (create/update/delete
  and analysis-summary saves all clear the relevant prefix).

- Add ?analysisOnly=true to /daily-statuses: filters rows where
  analysis_summary IS NOT NULL. Charts are the only consumer and already
  discard rows without analysis data; pre-filtering server-side avoids
  sending the full status payload (71 rows → 1 for this experiment).

- ExperimentCalendar: remove allStatuses prop, fetch calendar data
  directly via API. The component now issues one small GET per field
  selection change instead of the parent loading thousands of rows upfront.

- ExperimentDetail: pass analysisOnly:true to getDailyStatuses;
  drop allStatuses prop from ExperimentCalendar. Cold experiment page
  load no longer fetches every daily status record.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:03:59 -04:00
Experiments DB Dev 52d5fca5d1 feat: store matching metadata on session file registration
Each saved file now records three additional fields:
  matched_identifier    — the literal animal_id_string or animal_name
                          substring that matched in the filename
  animal_id_string_snap — snapshot of animal.animal_id_string at
                          registration time
  animal_name_snap      — snapshot of animal.animal_name at
                          registration time

This lets future consumers (scripts, exports, re-matching) identify
the subject from the file record alone, even if animal data changes.
The matched identifier is also shown in the drop preview UI (↳ 2852).

Tests: 25 total (+2 for new payload fields).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:41:36 -04:00
Experiments DB Dev 5cd6d2a8a3 fix: move session file registry above modification history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:37:30 -04:00
Experiments DB Dev b73768ee29 feat: add session file registry to daily status detail page
FileDropZone now appears on each subject's individual daily status
page, scoped to that one status. Files are matched against that
subject's animal_id_string and animal_name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:36:33 -04:00
Experiments DB Dev 66e79c1780 fix: match filenames against both animal_id_string and animal_name
Multi-word names ("Rat A") now allow any word separator in the
filename — space, underscore, or dash — so "Rat_A_session.csv"
and "Rat-A-data.bin" both match. Single-word names and numeric IDs
are unchanged. Partial overlaps still rejected ("Rat" won't hit
"Rationale"). Tests: 5 new name-based match cases (24 total).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:33:10 -04:00
Experiments DB Dev daf36908ef feat: session file registry with drag-and-drop on day view
Adds a FileDropZone at the bottom of the day view where users can
drop all session files at once. Filenames are regex-matched to
subjects using their animal_id_string (word-boundary aware, so
"2852" won't match "28520"). Only metadata is recorded — files
are never uploaded.

Backend: new session_files table (id, daily_status_id, filename,
file_size BIGINT, file_type, last_modified, notes). Routes:
  POST /api/daily-statuses/:id/files  (batch create)
  GET  /api/daily-statuses/:id/files
  DELETE /api/session-files/:id

Frontend: FileDropZone shows per-subject match groups, unmatched
files, and a warning for subjects with no match. After saving,
registered files are displayed with delete capability.

test-files/: generate_test_files.sh creates 4 dummy files per
subject (2×bin, 2×csv) with correct naming for drag-and-drop
testing. Pre-generated for 2026-04-23 with IDs 2852/3076/3077/3078.

Tests: 19 tests covering drag state, regex matching, save payload,
partial-overlap prevention, multi-subject batching, and delete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:12:45 -04:00
Experiments DB Dev dc46af8d31 feat: inline cell editing and prev/next day navigation in day view
Clicking any table cell opens an inline input (text or textarea per
field type). Enter or blur saves; Escape cancels. Unchanged values
skip the API call. Custom fields merge with existing custom_fields;
builtin fields use their key directly.

Prev/next buttons at the bottom navigate to adjacent days that have
data for the selected field, preserving the ?field= param. Day list
is fetched via getCalendar and sorted chronologically.

Tests: 41 total (added 15 new tests for cell editing and day nav).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:59:34 -04:00
Experiments DB Dev 6dfb427f91 test: add ExperimentDayView test suite (26 tests)
Covers loading/error states, breadcrumb, row filtering by selected
field (custom and builtin), table structure, subject-name navigation,
edit modal open/close/submit, session metrics bar chart visibility,
and API call parameters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:53:54 -04:00
Experiments DB Dev c8f0c5f298 feat: replace calendar day modal with dedicated day-view page
Calendar day clicks now navigate to /experiments/:id/day/:date
(with ?field= param). ExperimentDayView shows a sticky-column table
of all subjects with data that day, inline edit via modal, and a
session-metrics bar chart from analysis_summary. Subject name links
to /daily-statuses/:id. Backend adds ?date= filtering to
GET /experiments/:id/daily-statuses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:46:35 -04:00
Experiments DB Dev 6ecc6dc19a test: document and test analysis_summary bar chart contract
5 tests cover the bar chart rules:
- Shows when analysis_summary.total > 0
- Hidden when no summary or total=0
- Multiple animals each with their own summary
- Animals excluded from day view (empty field) also excluded from chart
- animalsWithData now filters by selected field value (not just record presence)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:31:36 -04:00
Experiments DB Dev 5f3ad1d408 fix: bar chart uses analysis_summary from each day's status records
X-axis: animals with valid field data on that day.
Bars: Success / Failure / Total from status.analysis_summary for that day.
Each day shows its own session metrics. Animals without analysis_summary
or with total=0 are excluded from the chart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:29:15 -04:00
Experiments DB Dev b888c1b76d fix: day modal bar chart plots that session's field values per animal
X-axis: animal names, Y-axis: the selected field's numeric value recorded
that specific day. Animals with empty or non-numeric values are excluded.
Each day's chart is independent — different animals, different values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:24:16 -04:00
Experiments DB Dev 7439a1a101 fix: day modal bar chart shows experiment-wide session stats per subject
Shows Total / Success / Failure bars across ALL sessions for each animal
that has at least one valid (non-empty) value for the chosen field.
Animals with zero valid field entries are excluded from the chart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:21:52 -04:00
Experiments DB Dev 9b51250d8e fix: bar chart in day modal shows that day's field values per subject
Previously showed cumulative success/failure counts across all days.
Now shows the selected field's actual value for each subject on that
specific day — numeric values only, subjects with empty or non-numeric
values are excluded from the chart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:18:48 -04:00
Experiments DB Dev 8b40856205 fix: hide subjects with no data in day modal; fix failure bar rendering
- Day modal now only shows animals that have a status record for that day
- Removed '+ Add' button and 'No data' placeholder for absent subjects
- Fixed failure bars disappearing in recharts: removed radius from all bars,
  added minPointSize=2 on failure bar so zero-value bars remain visible

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:13:05 -04:00
Experiments DB Dev d764bc66fb fix: move bar chart inside day modal instead of below calendar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:06:52 -04:00
Experiments DB Dev 4a048fda88 feat: permanent calendar with editable day modal and per-subject bar chart
- Calendar moved out of List/Group tab toggle — always visible below cross-
  subject analytics (ExperimentAnalysisCharts)
- Day modal shows all subjects: existing data as read-only + Edit button,
  missing data as + Add button; both open DailyStatusForm inline
- Status changes/creates propagate up to parent via onStatusChange/onStatusCreate
  and calendar counts recompute locally (no extra API round-trip)
- Bar chart (recharts) at bottom: Total/Success/Failure bars per subject plus
  % complete text, grouped by subject name
- Tests updated: 17 ExperimentCalendar tests, 76 total passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 11:30:26 -04:00
Experiments DB Dev 755caca09e feat: permanent calendar below analytics, editable day modal, per-subject bar chart 2026-04-23 15:24:00 +00:00
Experiments DB Dev fe23085e0f fix: resolve DailyStatusForm test stall and add label accessibility
- Use module-level EMPTY_TEMPLATE constant as default prop to prevent
  useEffect infinite loop (new [] each render changed identity every cycle)
- Add htmlFor to date and dynamic field labels for proper a11y association
- Add role=alert to date error paragraph so tests can find it
- Update tests to pass a template with builtin fields (required by template-
  driven component design) and mock experimentsApi
- Add --max-old-space-size=8192 to test scripts to prevent OOM on full suite

All 72 frontend tests now pass across 7 suites.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 10:41:17 -04:00
Experiments DB Dev 78fe3f68cc test: suppress act() console.error warnings in frontend test setup
Prevents OOM crashes when running the full Jest suite — the act() warning
stack traces were generating 16k+ lines of output which exhausted Node heap.
All assertions still run; only the noisy console.error spam is silenced.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 10:04:57 -04:00
Experiments DB Dev 9535f86970 feat: add experiment calendar view with per-day subject counts
New ExperimentCalendar component added as a third view mode (List / Group / Calendar)
on the Experiment Detail page.

Backend:
- GET /api/experiments/:id/calendar?field=<fieldIdOrBuiltinKey>
  Returns { field, days: { "YYYY-MM-DD": count } } where count = number of
  subjects with a non-null/non-empty value for the chosen field on that date.
  Supports both builtin fields (vitals, treatment, notes, experiment_description)
  and custom fields addressed by fieldId UUID.

Frontend:
- ExperimentCalendar component: navigable monthly grid, field selector
  (defaults to first field with "weight" in label, else first active field),
  blue badges showing subject count per day, click to open modal with all
  subjects' data for that day.
- Integrated into ExperimentDetail as displayMode "calendar", persisted
  in localStorage alongside the existing list/group modes.
- experimentsApi.getCalendar() added to API client.

Tests:
- backend/tests/calendar.test.js: 8 unit tests (404, empty days, builtin
  count, custom field count, whitespace ignored, multi-month, field echo,
  missing param).
- frontend/tests/ExperimentCalendar.test.jsx: 13 RTL tests covering
  rendering, default field selection, count badges, month navigation,
  field-change re-fetch, day click modal, and multi-subject display.

All 47 backend + 13 calendar frontend tests passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 09:26:35 -04:00
Experiments DB Dev 09a853e28b merge: stable template fieldIds into main 2026-04-15 14:01:18 -04:00
Experiments DB Dev b7374777d3 feat(template): stable fieldId per field — custom_fields keyed by UUID, not label slug 2026-04-15 14:01:14 -04:00
Experiments DB Dev 1305e53f96 merge: daily record template feature into main 2026-04-15 13:56:20 -04:00
Experiments DB Dev 86a56427b7 feat(template): per-experiment daily record template — rename/add/hide fields, custom_fields JSONB 2026-04-15 13:56:17 -04:00
Experiments DB Dev 80fb1c6e27 fix(docker): openssl in Alpine, binaryTargets for Prisma, IPv4 healthcheck, start_period for migration delay 2026-04-15 13:43:20 -04:00
Experiments DB Dev b03566e658 merge: red team security fixes into main 2026-04-15 13:25:23 -04:00
Experiments DB Dev 9b3c883bf0 fix(security): rate limiting, CORS restriction, tableName allowlist, non-root Docker, migration entrypoint, aria-live 2026-04-15 13:25:18 -04:00
Experiments DB Dev 5460a93217 merge: test suite into main 2026-04-15 13:23:51 -04:00
Experiments DB Dev 5874ed8374 feat(tests): Jest backend (38 tests), Jest RTL frontend (28 tests), Playwright E2E spec 2026-04-15 13:23:48 -04:00
Experiments DB Dev c359cb4bb9 merge: frontend UI into main 2026-04-15 13:18:34 -04:00
108 changed files with 24868 additions and 3974 deletions
+9 -1
View File
@@ -1,4 +1,6 @@
FROM node:20-alpine AS base
# Prisma migration engine requires OpenSSL
RUN apk add --no-cache openssl
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
@@ -9,6 +11,12 @@ COPY . .
CMD ["npm", "run", "dev"]
FROM base AS prod
# Install prisma CLI for migrate deploy at runtime
RUN npm install prisma --save-dev
COPY . .
# Generate Prisma Client
RUN npx prisma generate
CMD ["npm", "start"]
# Fix ownership so the node user can write engine cache
RUN chown -R node:node /app
USER node
CMD ["/bin/sh", "/app/docker-entrypoint.sh"]
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
set -e
echo "Running Prisma migrations..."
npx prisma migrate deploy
echo "Starting server..."
exec node src/index.js
+39
View File
@@ -11,6 +11,7 @@
"@prisma/client": "^5.14.0",
"cors": "^2.8.5",
"express": "^4.19.2",
"express-rate-limit": "^8.3.2",
"express-validator": "^7.1.0",
"helmet": "^7.1.0",
"morgan": "^1.10.0",
@@ -2064,6 +2065,23 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
"integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
"dependencies": {
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/express-validator": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz",
@@ -2489,6 +2507,14 @@
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -6396,6 +6422,14 @@
"vary": "~1.1.2"
}
},
"express-rate-limit": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
"integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
"requires": {
"ip-address": "10.1.0"
}
},
"express-validator": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz",
@@ -6697,6 +6731,11 @@
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="
},
"ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+4 -2
View File
@@ -16,6 +16,7 @@
"@prisma/client": "^5.14.0",
"cors": "^2.8.5",
"express": "^4.19.2",
"express-rate-limit": "^8.3.2",
"express-validator": "^7.1.0",
"helmet": "^7.1.0",
"morgan": "^1.10.0",
@@ -29,8 +30,9 @@
},
"jest": {
"testEnvironment": "node",
"testMatch": ["**/tests/**/*.test.js"],
"setupFilesAfterFramework": [],
"testMatch": [
"**/tests/**/*.test.js"
],
"globalSetup": "./tests/setup.js",
"globalTeardown": "./tests/teardown.js"
}
@@ -0,0 +1,5 @@
-- Add template field to experiments (JSON array of field definitions)
ALTER TABLE "experiments" ADD COLUMN "template" JSONB NOT NULL DEFAULT '[]';
-- Add custom_fields to daily_statuses (stores values for non-builtin fields)
ALTER TABLE "daily_statuses" ADD COLUMN "custom_fields" JSONB;
@@ -0,0 +1,2 @@
ALTER TABLE "experiments" ADD COLUMN "subject_info_template" JSONB NOT NULL DEFAULT '[]';
ALTER TABLE "animals" ADD COLUMN "subject_info" JSONB;
@@ -0,0 +1,21 @@
CREATE TABLE "daily_analyses" (
"id" TEXT NOT NULL,
"daily_status_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"file_name" TEXT,
"timestamp_col" TEXT NOT NULL,
"note_col" TEXT NOT NULL,
"classifications" JSONB NOT NULL,
"total_note_rows" INTEGER NOT NULL,
"sequence" JSONB NOT NULL,
"category_counts" JSONB NOT NULL,
"consecutive_stats" JSONB NOT NULL,
"runs" JSONB NOT NULL,
CONSTRAINT "daily_analyses_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "daily_analyses"
ADD CONSTRAINT "daily_analyses_daily_status_id_fkey"
FOREIGN KEY ("daily_status_id")
REFERENCES "daily_statuses"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1 @@
ALTER TABLE "daily_analyses" ADD COLUMN "session_end_ts" DOUBLE PRECISION;
@@ -0,0 +1 @@
ALTER TABLE "daily_statuses" ADD COLUMN "analysis_summary" JSONB;
@@ -0,0 +1,18 @@
CREATE TABLE "session_files" (
"id" TEXT NOT NULL,
"daily_status_id" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"file_size" BIGINT NOT NULL,
"file_type" TEXT,
"last_modified" BIGINT,
"notes" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "session_files_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "session_files"
ADD CONSTRAINT "session_files_daily_status_id_fkey"
FOREIGN KEY ("daily_status_id")
REFERENCES "daily_statuses"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,4 @@
ALTER TABLE "session_files"
ADD COLUMN "matched_identifier" TEXT,
ADD COLUMN "animal_id_string_snap" TEXT,
ADD COLUMN "animal_name_snap" TEXT;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "animals" ADD COLUMN "csv_analysis_config" JSONB;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "experiments" ADD COLUMN "plot_config" JSONB;
+46
View File
@@ -1,5 +1,6 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
}
datasource db {
@@ -11,6 +12,9 @@ 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")
@@ -21,6 +25,8 @@ model Animal {
experiment_id String
animal_id_string String
animal_name String
subject_info Json?
csv_analysis_config Json?
experiment Experiment @relation(fields: [experiment_id], references: [id], onDelete: Cascade)
daily_statuses DailyStatus[]
@@ -35,11 +41,51 @@ model DailyStatus {
vitals String?
treatment String?
notes String?
custom_fields Json?
analysis_summary Json?
animal Animal @relation(fields: [animal_id], references: [id], onDelete: Cascade)
analyses DailyAnalysis[]
session_files SessionFile[]
@@map("daily_statuses")
}
model SessionFile {
id String @id @default(uuid())
daily_status_id String
daily_status DailyStatus @relation(fields: [daily_status_id], references: [id], onDelete: Cascade)
filename String
file_size BigInt
file_type String?
last_modified BigInt?
matched_identifier String? // the animal ID/name substring that matched in the filename
animal_id_string_snap String? // snapshot of animal.animal_id_string at registration time
animal_name_snap String? // snapshot of animal.animal_name at registration time
notes String?
created_at DateTime @default(now())
@@map("session_files")
}
model DailyAnalysis {
id String @id @default(uuid())
daily_status_id String
daily_status DailyStatus @relation(fields: [daily_status_id], references: [id], onDelete: Cascade)
created_at DateTime @default(now())
file_name String?
timestamp_col String
note_col String
classifications Json
total_note_rows Int
session_end_ts Float?
sequence Json
category_counts Json
consecutive_stats Json
runs Json
@@map("daily_analyses")
}
model AuditLog {
id String @id @default(uuid())
table_name String
+75
View File
@@ -0,0 +1,75 @@
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const rateLimit = require('express-rate-limit');
const { globalErrorHandler } = require('./middleware/errorHandler');
const experimentsRouter = require('./routes/experiments');
const animalsRouter = require('./routes/animals');
const dailyStatusesRouter = require('./routes/dailyStatuses');
const auditLogsRouter = require('./routes/auditLogs');
const analysesRouter = require('./routes/analyses');
const sessionFilesRouter = require('./routes/sessionFiles');
const app = express();
// Security headers
app.use(helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' },
}));
// CORS — restrict to the configured frontend origin in production
const allowedOrigin = process.env.CORS_ORIGIN || '*';
app.use(cors({
origin: allowedOrigin,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
// Rate limiting — 100 requests per minute per IP on all API routes
const apiLimiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later.' },
skip: () => process.env.NODE_ENV === 'test',
});
app.use(express.json({ limit: '1mb' }));
app.use(morgan(process.env.NODE_ENV === 'test' ? 'silent' : process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
// Health check (no rate limit)
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// DB info — returns database timezone (no rate limit)
const prisma = require('./lib/prisma');
app.get('/api/info', async (req, res) => {
try {
const [{ timezone }] = await prisma.$queryRaw`SELECT current_setting('TimeZone') AS timezone`;
res.json({ dbTimezone: timezone });
} catch {
res.json({ dbTimezone: null });
}
});
// Apply rate limiter to all API routes
app.use('/api', apiLimiter);
app.use('/api/experiments', experimentsRouter);
app.use('/api/animals', animalsRouter);
app.use('/api/daily-statuses', dailyStatusesRouter);
app.use('/api/audit-logs', auditLogsRouter);
app.use('/api', analysesRouter);
app.use('/api', sessionFilesRouter);
app.use((req, res) => {
res.status(404).json({ error: `Route ${req.method} ${req.path} not found` });
});
app.use(globalErrorHandler);
module.exports = app;
+2 -49
View File
@@ -1,58 +1,13 @@
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const prisma = require('./lib/prisma');
const { globalErrorHandler } = require('./middleware/errorHandler');
const app = require('./app');
const experimentsRouter = require('./routes/experiments');
const animalsRouter = require('./routes/animals');
const dailyStatusesRouter = require('./routes/dailyStatuses');
const auditLogsRouter = require('./routes/auditLogs');
const app = express();
const PORT = process.env.PORT || 3001;
// Security & utility middleware
app.use(helmet());
app.use(cors({
origin: process.env.CORS_ORIGIN || '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
app.use(express.json({ limit: '1mb' }));
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// API routes
app.use('/api/experiments', experimentsRouter);
app.use('/api/animals', animalsRouter);
app.use('/api/daily-statuses', dailyStatusesRouter);
app.use('/api/audit-logs', auditLogsRouter);
// 404 handler for unknown routes
app.use((req, res) => {
res.status(404).json({ error: `Route ${req.method} ${req.path} not found` });
});
// Global error handler (must be last)
app.use(globalErrorHandler);
async function startServer() {
try {
await prisma.$connect();
console.log('Database connected successfully');
// Run migrations on startup in production
if (process.env.NODE_ENV === 'production') {
const { execSync } = require('child_process');
execSync('npx prisma migrate deploy', { stdio: 'inherit' });
}
// Note: migrations are run by docker-entrypoint.sh before this process starts
app.listen(PORT, '0.0.0.0', () => {
console.log(`Server running on port ${PORT}`);
});
@@ -63,5 +18,3 @@ async function startServer() {
}
startServer();
module.exports = app; // export for testing
+33
View File
@@ -0,0 +1,33 @@
// Lightweight in-process TTL cache with prefix-based invalidation.
// Single-process only — suitable for this single-container deployment.
const DEFAULT_TTL_MS = 10 * 60 * 1000; // 10 minutes
const store = new Map(); // key → { value, expiresAt }
function set(key, value, ttlMs = DEFAULT_TTL_MS) {
store.set(key, { value, expiresAt: Date.now() + ttlMs });
}
function get(key) {
const entry = store.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) {
store.delete(key);
return undefined;
}
return entry.value;
}
function del(key) {
store.delete(key);
}
// Remove all keys that start with prefix — used to invalidate a whole experiment's worth of cached data.
function delByPrefix(prefix) {
for (const key of store.keys()) {
if (key.startsWith(prefix)) store.delete(key);
}
}
module.exports = { set, get, del, delByPrefix };
+51
View File
@@ -0,0 +1,51 @@
const REQUIRED_DEFAULT_BUCKETS = ['Success', 'Failure', 'Other'];
const ALLOWED_TYPES = new Set(['note', 'numerical']);
/**
* Validate a csv_analysis_config payload.
* @param {unknown} body
* @returns {string|null} error message, or null if valid.
*/
function validateCsvConfig(body) {
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
return 'Body must be an object';
}
const { buckets } = body;
if (!Array.isArray(buckets)) {
return 'buckets must be an array';
}
const seenNames = new Set();
for (const b of buckets) {
if (b === null || typeof b !== 'object' || Array.isArray(b)) {
return 'Each bucket must be an object';
}
if (typeof b.name !== 'string' || b.name.trim() === '') {
return 'Each bucket must have a non-empty name';
}
if (seenNames.has(b.name)) {
return `Duplicate bucket name: ${b.name}`;
}
seenNames.add(b.name);
if (!ALLOWED_TYPES.has(b.type)) {
return `Invalid type for bucket ${b.name}: ${b.type}`;
}
if (!Array.isArray(b.notes)) {
return `notes must be an array for bucket ${b.name}`;
}
if (!b.notes.every((n) => typeof n === 'string')) {
return `notes must be an array of strings for bucket ${b.name}`;
}
}
for (const required of REQUIRED_DEFAULT_BUCKETS) {
if (!seenNames.has(required)) {
return `Missing required default bucket: ${required}`;
}
}
return null;
}
module.exports = { validateCsvConfig, REQUIRED_DEFAULT_BUCKETS };
+27
View File
@@ -0,0 +1,27 @@
/**
* The default daily-record template applied to every new experiment.
*
* `fieldId` — stable UUID; used as the storage key in custom_fields.
* Builtin fields have fixed canonical UUIDs so they are
* recognised across all experiments.
* `key` — human-readable slug (display / validation only)
* `builtin` — true → value stored in the dedicated DB column
* false → value stored in daily_statuses.custom_fields[fieldId]
* `active` — false → hidden from forms (data preserved)
* `tags` — quick-fill presets, stored per field per experiment
*/
const DEFAULT_TEMPLATE = [
{ fieldId: '00000000-0000-0000-0000-000000000001', key: 'experiment_description', label: 'Experiment Description', type: 'textarea', builtin: true, active: true, tags: [] },
{ fieldId: '00000000-0000-0000-0000-000000000002', key: 'vitals', label: 'Vitals', type: 'text', builtin: true, active: true, tags: [] },
{ fieldId: '00000000-0000-0000-0000-000000000003', key: 'treatment', label: 'Treatment', type: 'text', builtin: true, active: true, tags: [] },
{ fieldId: '00000000-0000-0000-0000-000000000004', key: 'notes', label: 'Notes', type: 'textarea', builtin: true, active: true, tags: [] },
];
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function resolveTemplate(stored) {
if (!Array.isArray(stored) || stored.length === 0) return DEFAULT_TEMPLATE;
return stored;
}
module.exports = { DEFAULT_TEMPLATE, resolveTemplate, UUID_RE };
+16 -12
View File
@@ -21,36 +21,40 @@ function auditMiddleware(tableName, model, idParam = 'id') {
// Attach before-state so route handlers can reference it if needed
req._auditBefore = before;
// Wrap res.json to intercept the response
const originalJson = res.json.bind(res);
res.json = async function (data) {
// Only log on successful mutations
// Shared audit writer — called by both res.json and res.send interceptors
async function writeAuditLog() {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
const action = req.method === 'DELETE' ? 'DELETE' : 'UPDATE';
const after = action === 'DELETE' ? null : await model.findUnique({ where: { id: recordId } });
const changes = {
before,
after,
};
await prisma.auditLog.create({
data: {
table_name: tableName,
record_id: recordId,
action,
changes,
changes: { before, after },
},
});
} catch (auditErr) {
// Audit failures should never block the primary response
console.error('Audit log write failed:', auditErr.message);
}
}
}
// Wrap res.json (used by JSON-returning routes)
const originalJson = res.json.bind(res);
res.json = async function (data) {
await writeAuditLog();
return originalJson(data);
};
// Wrap res.send (used by DELETE 204 routes)
const originalSend = res.send.bind(res);
res.send = async function (data) {
await writeAuditLog();
return originalSend(data);
};
next();
} catch (err) {
next(err);
+79
View File
@@ -0,0 +1,79 @@
const router = require('express').Router();
const prisma = require('../lib/prisma');
// GET /api/daily-statuses/:statusId/analyses
router.get('/daily-statuses/:statusId/analyses', async (req, res, next) => {
try {
const analyses = await prisma.dailyAnalysis.findMany({
where: { daily_status_id: req.params.statusId },
orderBy: { created_at: 'desc' },
select: {
id: true,
created_at: true,
file_name: true,
timestamp_col: true,
note_col: true,
classifications: true,
total_note_rows: true,
session_end_ts: true,
category_counts: true,
consecutive_stats: true,
runs: true,
// sequence omitted from list — fetch individually when needed
},
});
res.json(analyses);
} catch (err) { next(err); }
});
// POST /api/daily-statuses/:statusId/analyses
router.post('/daily-statuses/:statusId/analyses', async (req, res, next) => {
try {
const status = await prisma.dailyStatus.findUnique({ where: { id: req.params.statusId } });
if (!status) return res.status(404).json({ error: 'Daily status not found' });
const { file_name, timestamp_col, note_col, classifications, total_note_rows,
session_end_ts, sequence, category_counts, consecutive_stats, runs } = req.body;
if (!timestamp_col || !note_col || classifications == null || total_note_rows == null
|| !sequence || !category_counts || !consecutive_stats || !runs) {
return res.status(422).json({ error: 'Missing required analysis fields' });
}
const analysis = await prisma.dailyAnalysis.create({
data: {
daily_status_id: req.params.statusId,
file_name: file_name ?? null,
timestamp_col,
note_col,
classifications,
total_note_rows,
session_end_ts: session_end_ts != null ? Number(session_end_ts) : null,
sequence,
category_counts,
consecutive_stats,
runs,
},
});
res.status(201).json(analysis);
} catch (err) { next(err); }
});
// GET /api/analyses/:id (full record including sequence)
router.get('/analyses/:id', async (req, res, next) => {
try {
const analysis = await prisma.dailyAnalysis.findUnique({ where: { id: req.params.id } });
if (!analysis) return res.status(404).json({ error: 'Analysis not found' });
res.json(analysis);
} catch (err) { next(err); }
});
// DELETE /api/analyses/:id
router.delete('/analyses/:id', async (req, res, next) => {
try {
await prisma.dailyAnalysis.delete({ where: { id: req.params.id } });
res.status(204).send();
} catch (err) { next(err); }
});
module.exports = router;
+26 -3
View File
@@ -3,9 +3,10 @@ const { body, query } = require('express-validator');
const prisma = require('../lib/prisma');
const auditMiddleware = require('../middleware/auditMiddleware');
const { validateRequest } = require('../middleware/errorHandler');
const { validateCsvConfig } = require('../lib/csvConfigValidation');
const animalValidation = [
body('experiment_id').notEmpty().withMessage('experiment_id is required').isUUID().withMessage('experiment_id must be a valid UUID'),
body('experiment_id').notEmpty().withMessage('experiment_id is required').matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('experiment_id must be a valid UUID'),
body('animal_id_string').trim().notEmpty().withMessage('animal_id_string is required'),
body('animal_name').trim().notEmpty().withMessage('animal_name is required'),
];
@@ -13,12 +14,13 @@ const animalValidation = [
const animalUpdateValidation = [
body('animal_id_string').optional().trim().notEmpty().withMessage('animal_id_string cannot be empty'),
body('animal_name').optional().trim().notEmpty().withMessage('animal_name cannot be empty'),
body('subject_info').optional({ nullable: true }).isObject().withMessage('subject_info must be an object'),
];
// GET /api/animals?experimentId=<uuid> — list for experiment
router.get(
'/',
[query('experimentId').optional().isUUID().withMessage('experimentId must be a valid UUID')],
[query('experimentId').optional().matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('experimentId must be a valid UUID')],
validateRequest,
async (req, res, next) => {
try {
@@ -80,10 +82,11 @@ router.put(
validateRequest,
async (req, res, next) => {
try {
const { animal_id_string, animal_name } = req.body;
const { animal_id_string, animal_name, subject_info } = req.body;
const data = {};
if (animal_id_string !== undefined) data.animal_id_string = animal_id_string;
if (animal_name !== undefined) data.animal_name = animal_name;
if (subject_info !== undefined) data.subject_info = subject_info;
const animal = await prisma.animal.update({
where: { id: req.params.id },
@@ -110,4 +113,24 @@ router.delete(
}
);
// PATCH /api/animals/:id/csv-config — replace entire CSV-analysis bucket config
router.patch(
'/:id/csv-config',
auditMiddleware('animals', prisma.animal),
async (req, res, next) => {
try {
const err = validateCsvConfig(req.body);
if (err) return res.status(400).json({ error: err });
const animal = await prisma.animal.update({
where: { id: req.params.id },
data: { csv_analysis_config: req.body },
});
res.json(animal);
} catch (e) {
next(e);
}
}
);
module.exports = router;
+2 -2
View File
@@ -7,8 +7,8 @@ const { validateRequest } = require('../middleware/errorHandler');
router.get(
'/',
[
query('tableName').optional().isString(),
query('recordId').optional().isUUID().withMessage('recordId must be a valid UUID'),
query('tableName').optional().isIn(['experiments', 'animals', 'daily_statuses', 'audit_logs']).withMessage('tableName must be one of: experiments, animals, daily_statuses, audit_logs'),
query('recordId').optional().matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('recordId must be a valid UUID'),
query('limit').optional().isInt({ min: 1, max: 200 }).withMessage('limit must be 1200').toInt(),
query('offset').optional().isInt({ min: 0 }).withMessage('offset must be ≥0').toInt(),
],
+80 -44
View File
@@ -1,30 +1,43 @@
const router = require('express').Router();
const { body, query } = require('express-validator');
const prisma = require('../lib/prisma');
const cache = require('../lib/cache');
const auditMiddleware = require('../middleware/auditMiddleware');
const { validateRequest } = require('../middleware/errorHandler');
// Look up which experiment a daily status belongs to, then clear all cached data for it.
async function invalidateByStatusId(statusId) {
const rec = await prisma.dailyStatus.findUnique({
where: { id: statusId },
select: { animal: { select: { experiment_id: true } } },
});
const expId = rec?.animal?.experiment_id;
if (expId) cache.delByPrefix(`exp:${expId}:`);
}
const statusValidation = [
body('animal_id').notEmpty().withMessage('animal_id is required').isUUID().withMessage('animal_id must be a valid UUID'),
body('animal_id').notEmpty().withMessage('animal_id is required').matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('animal_id must be a valid UUID'),
body('date').notEmpty().withMessage('date is required').isISO8601().withMessage('date must be a valid ISO 8601 date (YYYY-MM-DD)'),
body('experiment_description').optional().isString(),
body('vitals').optional().isString(),
body('treatment').optional().isString(),
body('notes').optional().isString(),
body('experiment_description').optional({ nullable: true }).isString(),
body('vitals').optional({ nullable: true }).isString(),
body('treatment').optional({ nullable: true }).isString(),
body('notes').optional({ nullable: true }).isString(),
body('custom_fields').optional({ nullable: true }).isObject().withMessage('custom_fields must be an object'),
];
const statusUpdateValidation = [
body('date').optional().isISO8601().withMessage('date must be a valid ISO 8601 date'),
body('experiment_description').optional().isString(),
body('vitals').optional().isString(),
body('treatment').optional().isString(),
body('notes').optional().isString(),
body('experiment_description').optional({ nullable: true }).isString(),
body('vitals').optional({ nullable: true }).isString(),
body('treatment').optional({ nullable: true }).isString(),
body('notes').optional({ nullable: true }).isString(),
body('custom_fields').optional({ nullable: true }).isObject().withMessage('custom_fields must be an object'),
];
// GET /api/daily-statuses?animalId=<uuid> — list for animal
// GET /api/daily-statuses?animalId=<uuid>
router.get(
'/',
[query('animalId').optional().isUUID().withMessage('animalId must be a valid UUID')],
[query('animalId').optional().matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('animalId must be a valid UUID')],
validateRequest,
async (req, res, next) => {
try {
@@ -35,13 +48,11 @@ router.get(
include: { animal: { select: { animal_name: true, animal_id_string: true } } },
});
res.json(statuses);
} catch (err) {
next(err);
}
} catch (err) { next(err); }
}
);
// GET /api/daily-statuses/:id — get one
// GET /api/daily-statuses/:id
router.get('/:id', async (req, res, next) => {
try {
const status = await prisma.dailyStatus.findUnique({
@@ -50,15 +61,13 @@ router.get('/:id', async (req, res, next) => {
});
if (!status) return res.status(404).json({ error: 'Daily status not found' });
res.json(status);
} catch (err) {
next(err);
}
} catch (err) { next(err); }
});
// POST /api/daily-statuses — create
// POST /api/daily-statuses
router.post('/', statusValidation, validateRequest, async (req, res, next) => {
try {
const { animal_id, date, experiment_description, vitals, treatment, notes } = req.body;
const { animal_id, date, experiment_description, vitals, treatment, notes, custom_fields } = req.body;
const status = await prisma.dailyStatus.create({
data: {
animal_id,
@@ -67,25 +76,20 @@ router.post('/', statusValidation, validateRequest, async (req, res, next) => {
vitals,
treatment,
notes,
custom_fields: custom_fields ?? {},
},
});
await prisma.auditLog.create({
data: {
table_name: 'daily_statuses',
record_id: status.id,
action: 'CREATE',
changes: { before: null, after: status },
},
data: { table_name: 'daily_statuses', record_id: status.id, action: 'CREATE', changes: { before: null, after: status } },
});
// Invalidate cached experiment data — new status may change calendar counts and chart data
const animal = await prisma.animal.findUnique({ where: { id: animal_id }, select: { experiment_id: true } });
if (animal) cache.delByPrefix(`exp:${animal.experiment_id}:`);
res.status(201).json(status);
} catch (err) {
next(err);
}
} catch (err) { next(err); }
});
// PUT /api/daily-statuses/:id — update
// PUT /api/daily-statuses/:id
router.put(
'/:id',
auditMiddleware('daily_statuses', prisma.dailyStatus),
@@ -93,36 +97,68 @@ router.put(
validateRequest,
async (req, res, next) => {
try {
const { date, experiment_description, vitals, treatment, notes } = req.body;
const { date, experiment_description, vitals, treatment, notes, custom_fields } = req.body;
const data = {};
if (date !== undefined) data.date = new Date(date);
if (experiment_description !== undefined) data.experiment_description = experiment_description;
if (vitals !== undefined) data.vitals = vitals;
if (treatment !== undefined) data.treatment = treatment;
if (notes !== undefined) data.notes = notes;
if (custom_fields !== undefined) data.custom_fields = custom_fields;
const status = await prisma.dailyStatus.update({
where: { id: req.params.id },
data,
});
const status = await prisma.dailyStatus.update({ where: { id: req.params.id }, data });
await invalidateByStatusId(req.params.id);
res.json(status);
} catch (err) {
next(err);
}
} catch (err) { next(err); }
}
);
// DELETE /api/daily-statuses/:id — delete
// PUT /api/daily-statuses/:id/analysis-summary
router.put('/:id/analysis-summary', async (req, res, next) => {
try {
const status = await prisma.dailyStatus.findUnique({
where: { id: req.params.id },
include: { animal: { select: { experiment_id: true } } },
});
if (!status) return res.status(404).json({ error: 'Daily status not found' });
const { counts, total, success_rate, source_analysis_id } = req.body;
if (!counts || total == null) {
return res.status(422).json({ error: 'counts and total are required' });
}
const summary = {
counts,
total,
success_rate: success_rate ?? null,
source_analysis_id: source_analysis_id ?? null,
computed_at: new Date().toISOString(),
};
const updated = await prisma.dailyStatus.update({
where: { id: req.params.id },
data: { analysis_summary: summary },
});
if (status.animal?.experiment_id) cache.delByPrefix(`exp:${status.animal.experiment_id}:`);
res.json(updated);
} catch (err) { next(err); }
});
// DELETE /api/daily-statuses/:id
router.delete(
'/:id',
auditMiddleware('daily_statuses', prisma.dailyStatus),
async (req, res, next) => {
try {
// Resolve experiment before deleting so we can invalidate after
const rec = await prisma.dailyStatus.findUnique({
where: { id: req.params.id },
select: { animal: { select: { experiment_id: true } } },
});
await prisma.dailyStatus.delete({ where: { id: req.params.id } });
if (rec?.animal?.experiment_id) cache.delByPrefix(`exp:${rec.animal.experiment_id}:`);
res.status(204).send();
} catch (err) {
next(err);
}
} catch (err) { next(err); }
}
);
+384 -33
View File
@@ -1,14 +1,108 @@
const router = require('express').Router();
const { body } = require('express-validator');
const prisma = require('../lib/prisma');
const cache = require('../lib/cache');
const auditMiddleware = require('../middleware/auditMiddleware');
const { validateRequest } = require('../middleware/errorHandler');
const { v4: uuidv4 } = require('uuid');
const { resolveTemplate, DEFAULT_TEMPLATE, UUID_RE } = require('../lib/defaultTemplate');
const experimentValidation = [
body('title').trim().notEmpty().withMessage('Title is required').isLength({ max: 255 }).withMessage('Title must be ≤255 characters'),
];
// GET /api/experiments — list all
// ── Template validation helpers ────────────────────────────────────────────────
const BUILTIN_FIELD_IDS = new Set(DEFAULT_TEMPLATE.map((f) => f.fieldId));
const BUILTIN_KEYS = new Set(DEFAULT_TEMPLATE.map((f) => f.key));
const VALID_TYPES = new Set(['text', 'textarea']);
function validateTemplate(template) {
if (!Array.isArray(template)) return 'template must be an array';
if (template.length > 50) return 'template may have at most 50 fields';
const seenFieldIds = new Set();
const seenKeys = new Set();
for (const field of template) {
// ── fieldId ────────────────────────────────────────────────────────────────
if (!field.fieldId || !UUID_RE.test(field.fieldId))
return `each field must have a valid UUID fieldId (got: ${field.fieldId})`;
if (seenFieldIds.has(field.fieldId))
return `duplicate fieldId "${field.fieldId}"`;
seenFieldIds.add(field.fieldId);
// Builtin fields must keep their original fieldIds
if (BUILTIN_KEYS.has(field.key) && !BUILTIN_FIELD_IDS.has(field.fieldId))
return `builtin field "${field.key}" must keep its original fieldId`;
// ── key ────────────────────────────────────────────────────────────────────
if (!field.key || typeof field.key !== 'string') return 'each field must have a string key';
if (!/^[a-z0-9_]+$/.test(field.key)) return `key "${field.key}" must be lowercase alphanumeric/underscore only`;
if (field.key.length > 64) return `key "${field.key}" must be ≤64 characters`;
if (seenKeys.has(field.key)) return `duplicate key "${field.key}"`;
seenKeys.add(field.key);
// ── label ──────────────────────────────────────────────────────────────────
if (!field.label || typeof field.label !== 'string' || !field.label.trim())
return `field "${field.key}" must have a non-empty label`;
if (field.label.length > 128) return `label for "${field.key}" must be ≤128 characters`;
// ── type / active / builtin ────────────────────────────────────────────────
if (!VALID_TYPES.has(field.type)) return `field "${field.key}" type must be "text" or "textarea"`;
if (typeof field.active !== 'boolean') return `field "${field.key}" must have a boolean active flag`;
if (BUILTIN_KEYS.has(field.key) && field.builtin !== true)
return `field "${field.key}" is a builtin field and cannot be made non-builtin`;
if (!BUILTIN_KEYS.has(field.key) && field.builtin !== false)
return `custom field "${field.key}" must have builtin: false`;
// ── abbr / unit / width ────────────────────────────────────────────────────
if (field.abbr !== undefined && field.abbr !== null) {
if (typeof field.abbr !== 'string') return `abbr for "${field.key}" must be a string`;
if (field.abbr.length > 10) return `abbr for "${field.key}" must be ≤10 characters`;
}
if (field.unit !== undefined && field.unit !== null) {
if (typeof field.unit !== 'string') return `unit for "${field.key}" must be a string`;
if (field.unit.length > 10) return `unit for "${field.key}" must be ≤10 characters`;
}
if (field.width !== undefined && field.width !== null) {
if (!Number.isInteger(field.width) || field.width < 1 || field.width > 100)
return `width for "${field.key}" must be an integer between 1 and 100`;
}
// ── tags ───────────────────────────────────────────────────────────────────
if (field.tags !== undefined) {
if (!Array.isArray(field.tags)) return `tags for "${field.key}" must be an array`;
if (field.tags.length > 50) return `field "${field.key}" may have at most 50 tags`;
for (const tag of field.tags) {
if (typeof tag !== 'string' || !tag.trim()) return `tags for "${field.key}" must be non-empty strings`;
if (tag.length > 512) return `a tag in "${field.key}" exceeds 512 characters`;
}
}
if (field.tagsDisabled !== undefined && typeof field.tagsDisabled !== 'boolean')
return `tagsDisabled for "${field.key}" must be a boolean`;
}
return null;
}
/**
* Back-fill any missing fieldIds (handles records saved before this feature).
* Builtin fields get their canonical IDs; custom fields get fresh UUIDs.
*/
function ensureFieldIds(template) {
const builtinById = Object.fromEntries(DEFAULT_TEMPLATE.map((f) => [f.key, f.fieldId]));
return template.map((f) => ({
...f,
fieldId: f.fieldId || (f.builtin ? builtinById[f.key] : uuidv4()),
tags: f.tags ?? [], // always return tags array (empty if not yet set)
}));
}
// ── Experiment CRUD ────────────────────────────────────────────────────────────
// GET /api/experiments
router.get('/', async (req, res, next) => {
try {
const experiments = await prisma.experiment.findMany({
@@ -16,12 +110,10 @@ router.get('/', async (req, res, next) => {
include: { _count: { select: { animals: true } } },
});
res.json(experiments);
} catch (err) {
next(err);
}
} catch (err) { next(err); }
});
// GET /api/experiments/:id — get one with animals
// GET /api/experiments/:id
router.get('/:id', async (req, res, next) => {
try {
const experiment = await prisma.experiment.findUnique({
@@ -29,35 +121,25 @@ router.get('/:id', async (req, res, next) => {
include: { animals: true },
});
if (!experiment) return res.status(404).json({ error: 'Experiment not found' });
experiment.template = ensureFieldIds(resolveTemplate(experiment.template));
res.json(experiment);
} catch (err) {
next(err);
}
} catch (err) { next(err); }
});
// POST /api/experiments — create
// POST /api/experiments
router.post('/', experimentValidation, validateRequest, async (req, res, next) => {
try {
const { title } = req.body;
const experiment = await prisma.experiment.create({ data: { title } });
// Write CREATE audit log
await prisma.auditLog.create({
data: {
table_name: 'experiments',
record_id: experiment.id,
action: 'CREATE',
changes: { before: null, after: experiment },
},
data: { table_name: 'experiments', record_id: experiment.id, action: 'CREATE', changes: { before: null, after: experiment } },
});
experiment.template = ensureFieldIds(resolveTemplate(experiment.template));
res.status(201).json(experiment);
} catch (err) {
next(err);
}
} catch (err) { next(err); }
});
// PUT /api/experiments/:id — update
// PUT /api/experiments/:id
router.put(
'/:id',
auditMiddleware('experiments', prisma.experiment),
@@ -66,18 +148,14 @@ router.put(
async (req, res, next) => {
try {
const { title } = req.body;
const experiment = await prisma.experiment.update({
where: { id: req.params.id },
data: { title },
});
const experiment = await prisma.experiment.update({ where: { id: req.params.id }, data: { title } });
experiment.template = ensureFieldIds(resolveTemplate(experiment.template));
res.json(experiment);
} catch (err) {
next(err);
}
} catch (err) { next(err); }
}
);
// DELETE /api/experiments/:id — delete
// DELETE /api/experiments/:id
router.delete(
'/:id',
auditMiddleware('experiments', prisma.experiment),
@@ -85,10 +163,283 @@ router.delete(
try {
await prisma.experiment.delete({ where: { id: req.params.id } });
res.status(204).send();
} catch (err) {
next(err);
}
} catch (err) { next(err); }
}
);
// ── Subject-template validation ────────────────────────────────────────────────
function validateSubjectTemplate(template) {
if (!Array.isArray(template)) return 'subject_info_template must be an array';
if (template.length > 50) return 'subject_info_template may have at most 50 fields';
const seenFieldIds = new Set();
const seenKeys = new Set();
for (const field of template) {
if (!field.fieldId || !UUID_RE.test(field.fieldId))
return `each field must have a valid UUID fieldId (got: ${field.fieldId})`;
if (seenFieldIds.has(field.fieldId)) return `duplicate fieldId "${field.fieldId}"`;
seenFieldIds.add(field.fieldId);
if (!field.key || typeof field.key !== 'string') return 'each field must have a string key';
if (!/^[a-z0-9_]+$/.test(field.key)) return `key "${field.key}" must be lowercase alphanumeric/underscore only`;
if (field.key.length > 64) return `key "${field.key}" must be ≤64 characters`;
if (seenKeys.has(field.key)) return `duplicate key "${field.key}"`;
seenKeys.add(field.key);
if (!field.label || typeof field.label !== 'string' || !field.label.trim())
return `field "${field.key}" must have a non-empty label`;
if (field.label.length > 128) return `label for "${field.key}" must be ≤128 characters`;
if (!VALID_TYPES.has(field.type)) return `field "${field.key}" type must be "text" or "textarea"`;
if (typeof field.active !== 'boolean') return `field "${field.key}" must have a boolean active flag`;
if (field.builtin !== false) return `subject field "${field.key}" must have builtin: false`;
if (field.abbr !== undefined && field.abbr !== null) {
if (typeof field.abbr !== 'string') return `abbr for "${field.key}" must be a string`;
if (field.abbr.length > 10) return `abbr for "${field.key}" must be ≤10 characters`;
}
if (field.unit !== undefined && field.unit !== null) {
if (typeof field.unit !== 'string') return `unit for "${field.key}" must be a string`;
if (field.unit.length > 10) return `unit for "${field.key}" must be ≤10 characters`;
}
if (field.width !== undefined && field.width !== null) {
if (!Number.isInteger(field.width) || field.width < 1 || field.width > 100)
return `width for "${field.key}" must be an integer between 1 and 100`;
}
if (field.tags !== undefined) {
if (!Array.isArray(field.tags)) return `tags for "${field.key}" must be an array`;
if (field.tags.length > 50) return `field "${field.key}" may have at most 50 tags`;
for (const tag of field.tags) {
if (typeof tag !== 'string' || !tag.trim()) return `tags for "${field.key}" must be non-empty strings`;
if (tag.length > 512) return `a tag in "${field.key}" exceeds 512 characters`;
}
}
if (field.tagsDisabled !== undefined && typeof field.tagsDisabled !== 'boolean')
return `tagsDisabled for "${field.key}" must be a boolean`;
}
return null;
}
// 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.
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
router.get('/:id/template', async (req, res, next) => {
try {
const experiment = await prisma.experiment.findUnique({
where: { id: req.params.id },
select: { id: true, template: true },
});
if (!experiment) return res.status(404).json({ error: 'Experiment not found' });
res.json(ensureFieldIds(resolveTemplate(experiment.template)));
} catch (err) { next(err); }
});
// PUT /api/experiments/:id/template
router.put('/:id/template', 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 template = req.body;
const validationError = validateTemplate(template);
if (validationError) return res.status(422).json({ error: validationError });
const before = ensureFieldIds(resolveTemplate(experiment.template));
const updated = await prisma.experiment.update({
where: { id: req.params.id },
data: { template },
});
await prisma.auditLog.create({
data: {
table_name: 'experiments',
record_id: experiment.id,
action: 'UPDATE',
changes: { before: { template: before }, after: { template } },
},
});
res.json(template);
} catch (err) { next(err); }
});
// GET /api/experiments/:id/subject-template
router.get('/:id/subject-template', async (req, res, next) => {
try {
const experiment = await prisma.experiment.findUnique({
where: { id: req.params.id },
select: { id: true, subject_info_template: true },
});
if (!experiment) return res.status(404).json({ error: 'Experiment not found' });
const tmpl = Array.isArray(experiment.subject_info_template) ? experiment.subject_info_template : [];
res.json(tmpl.map((f) => ({ ...f, tags: f.tags ?? [] })));
} catch (err) { next(err); }
});
// PUT /api/experiments/:id/subject-template
router.put('/:id/subject-template', 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 template = req.body;
const validationError = validateSubjectTemplate(template);
if (validationError) return res.status(422).json({ error: validationError });
const updated = await prisma.experiment.update({
where: { id: req.params.id },
data: { subject_info_template: template },
});
await prisma.auditLog.create({
data: {
table_name: 'experiments',
record_id: experiment.id,
action: 'UPDATE',
changes: { before: { subject_info_template: experiment.subject_info_template }, after: { subject_info_template: template } },
},
});
res.json(template);
} 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=<fieldId|builtinKey>
// Returns { field, days: { "YYYY-MM-DD": count } } where count = subjects with non-empty value that day.
router.get('/:id/calendar', async (req, res, next) => {
try {
const field = req.query.field ?? '';
const cacheKey = `exp:${req.params.id}:cal:${field}`;
const cached = cache.get(cacheKey);
if (cached) return res.json(cached);
const exp = await prisma.experiment.findUnique({ where: { id: req.params.id } });
if (!exp) return res.status(404).json({ error: 'Experiment not found' });
const BUILTIN_KEYS_SET = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
const isBuiltin = BUILTIN_KEYS_SET.has(field);
const statuses = await prisma.dailyStatus.findMany({
where: { animal: { experiment_id: req.params.id } },
select: {
date: true,
experiment_description: true,
vitals: true,
treatment: true,
notes: true,
custom_fields: true,
},
});
const days = {};
for (const s of statuses) {
const value = isBuiltin ? s[field] : s.custom_fields?.[field];
if (value !== null && value !== undefined && String(value).trim() !== '') {
const dateKey = new Date(s.date).toISOString().slice(0, 10);
days[dateKey] = (days[dateKey] || 0) + 1;
}
}
const result = { field, days };
cache.set(cacheKey, result);
res.json(result);
} catch (err) { next(err); }
});
// GET /api/experiments/:id/daily-statuses — all statuses for all subjects, analysis_summary included
// ?date=YYYY-MM-DD — filter to a single day
// ?analysisOnly=true — only return statuses that have a saved analysis_summary (for charts)
router.get('/:id/daily-statuses', async (req, res, next) => {
try {
const date = req.query.date ?? '';
const analysisOnly = req.query.analysisOnly === 'true';
const cacheKey = `exp:${req.params.id}:ds:${analysisOnly ? '1' : '0'}:${date}`;
const cached = cache.get(cacheKey);
if (cached) return res.json(cached);
const exp = await prisma.experiment.findUnique({ where: { id: req.params.id } });
if (!exp) return res.status(404).json({ error: 'Experiment not found' });
const where = { animal: { experiment_id: req.params.id } };
if (date) {
const d = new Date(date + 'T00:00:00.000Z');
const next = new Date(d); next.setUTCDate(next.getUTCDate() + 1);
where.date = { gte: d, lt: next };
}
let statuses = await prisma.dailyStatus.findMany({
where,
select: {
id: true,
animal_id: true,
date: true,
analysis_summary: true,
experiment_description: true,
vitals: true,
treatment: true,
notes: true,
custom_fields: true,
},
orderBy: { date: 'asc' },
});
// Prisma 5 has no clean IS NOT NULL filter for Json? fields; filter in JS.
// The result is cached so this only runs once per cache period.
if (analysisOnly) {
statuses = statuses.filter((s) => s.analysis_summary !== null);
}
cache.set(cacheKey, statuses);
res.json(statuses);
} catch (err) { next(err); }
});
module.exports = router;
+81
View File
@@ -0,0 +1,81 @@
const router = require('express').Router();
const prisma = require('../lib/prisma');
// POST /api/daily-statuses/:id/files — register file metadata (batch)
// Body: [{ filename, file_size, file_type, last_modified,
// matched_identifier, animal_id_string_snap, animal_name_snap, notes }, ...]
router.post('/daily-statuses/:id/files', async (req, res, next) => {
try {
const status = await prisma.dailyStatus.findUnique({ where: { id: req.params.id } });
if (!status) return res.status(404).json({ error: 'Daily status not found' });
const files = req.body;
if (!Array.isArray(files) || files.length === 0)
return res.status(422).json({ error: 'Request body must be a non-empty array of file metadata' });
if (files.length > 200)
return res.status(422).json({ error: 'At most 200 files per request' });
for (const f of files) {
if (!f.filename || typeof f.filename !== 'string' || !f.filename.trim())
return res.status(422).json({ error: 'Each file must have a non-empty filename' });
if (f.file_size == null || typeof f.file_size !== 'number' || f.file_size < 0)
return res.status(422).json({ error: `file_size must be a non-negative number (got: ${f.file_size})` });
}
const created = await prisma.$transaction(
files.map((f) =>
prisma.sessionFile.create({
data: {
daily_status_id: req.params.id,
filename: f.filename.trim(),
file_size: BigInt(Math.round(f.file_size)),
file_type: f.file_type ?? null,
last_modified: f.last_modified != null ? BigInt(Math.round(f.last_modified)) : null,
matched_identifier: f.matched_identifier ?? null,
animal_id_string_snap: f.animal_id_string_snap ?? null,
animal_name_snap: f.animal_name_snap ?? null,
notes: f.notes ?? null,
},
})
)
);
res.status(201).json(created.map(serializeFile));
} catch (err) { next(err); }
});
// GET /api/daily-statuses/:id/files
router.get('/daily-statuses/:id/files', async (req, res, next) => {
try {
const status = await prisma.dailyStatus.findUnique({ where: { id: req.params.id } });
if (!status) return res.status(404).json({ error: 'Daily status not found' });
const files = await prisma.sessionFile.findMany({
where: { daily_status_id: req.params.id },
orderBy: { created_at: 'asc' },
});
res.json(files.map(serializeFile));
} catch (err) { next(err); }
});
// DELETE /api/session-files/:id
router.delete('/session-files/:id', async (req, res, next) => {
try {
const file = await prisma.sessionFile.findUnique({ where: { id: req.params.id } });
if (!file) return res.status(404).json({ error: 'Session file not found' });
await prisma.sessionFile.delete({ where: { id: req.params.id } });
res.status(204).send();
} catch (err) { next(err); }
});
// BigInt fields must be serialized to strings to survive JSON.stringify
function serializeFile(f) {
return {
...f,
file_size: f.file_size.toString(),
last_modified: f.last_modified != null ? f.last_modified.toString() : null,
};
}
module.exports = router;
+37
View File
@@ -0,0 +1,37 @@
// Shared Prisma mock — individual test files override methods as needed.
const prismaMock = {
experiment: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
},
animal: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
},
dailyStatus: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
},
auditLog: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
count: jest.fn(),
},
$connect: jest.fn(),
$disconnect: jest.fn(),
};
module.exports = prismaMock;
+190
View File
@@ -0,0 +1,190 @@
const request = require('supertest');
jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma'));
const prisma = require('../src/lib/prisma');
const app = require('../src/app');
const EXP_ID = 'bbbbbbbb-0000-0000-0000-000000000001';
const ANIMAL = {
id: 'cccccccc-0000-0000-0000-000000000001',
experiment_id: EXP_ID,
animal_id_string: 'M-001',
animal_name: 'Whiskers',
_count: { daily_statuses: 0 },
};
beforeEach(() => jest.clearAllMocks());
describe('GET /api/animals', () => {
it('returns animals filtered by experimentId', async () => {
prisma.animal.findMany.mockResolvedValue([ANIMAL]);
const res = await request(app).get(`/api/animals?experimentId=${EXP_ID}`);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].animal_id_string).toBe('M-001');
});
it('returns 422 for invalid UUID experimentId', async () => {
const res = await request(app).get('/api/animals?experimentId=not-a-uuid');
expect(res.status).toBe(422);
});
});
describe('GET /api/animals/:id', () => {
it('returns animal with daily_statuses', async () => {
prisma.animal.findUnique.mockResolvedValue({ ...ANIMAL, daily_statuses: [] });
const res = await request(app).get(`/api/animals/${ANIMAL.id}`);
expect(res.status).toBe(200);
expect(res.body.animal_name).toBe('Whiskers');
});
it('returns 404 for unknown animal', async () => {
prisma.animal.findUnique.mockResolvedValue(null);
const res = await request(app).get('/api/animals/00000000-0000-0000-0000-000000000000');
expect(res.status).toBe(404);
});
});
describe('POST /api/animals', () => {
it('creates animal with UUID and writes CREATE audit log', async () => {
prisma.animal.create.mockResolvedValue(ANIMAL);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app)
.post('/api/animals')
.send({ experiment_id: EXP_ID, animal_id_string: 'M-001', animal_name: 'Whiskers' });
expect(res.status).toBe(201);
expect(res.body.id).toMatch(/^[0-9a-f-]{36}$/i);
expect(prisma.auditLog.create).toHaveBeenCalled();
});
it('returns 422 when experiment_id is missing', async () => {
const res = await request(app)
.post('/api/animals')
.send({ animal_id_string: 'M-001', animal_name: 'Whiskers' });
expect(res.status).toBe(422);
expect(res.body.details).toEqual(
expect.arrayContaining([expect.objectContaining({ field: 'experiment_id' })])
);
});
it('returns 422 when experiment_id is not a UUID', async () => {
const res = await request(app)
.post('/api/animals')
.send({ experiment_id: 'bad-id', animal_id_string: 'M-001', animal_name: 'Whiskers' });
expect(res.status).toBe(422);
});
it('returns 422 when animal_name is missing', async () => {
const res = await request(app)
.post('/api/animals')
.send({ experiment_id: EXP_ID, animal_id_string: 'M-001' });
expect(res.status).toBe(422);
});
});
describe('PUT /api/animals/:id', () => {
it('updates animal and writes UPDATE audit log', async () => {
const updated = { ...ANIMAL, animal_name: 'Mr Whiskers' };
prisma.animal.findUnique
.mockResolvedValueOnce(ANIMAL)
.mockResolvedValueOnce(updated);
prisma.animal.update.mockResolvedValue(updated);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app)
.put(`/api/animals/${ANIMAL.id}`)
.send({ animal_name: 'Mr Whiskers' });
expect(res.status).toBe(200);
expect(res.body.animal_name).toBe('Mr Whiskers');
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ action: 'UPDATE' }) })
);
});
});
describe('DELETE /api/animals/:id', () => {
it('deletes animal and writes DELETE audit log', async () => {
prisma.animal.findUnique.mockResolvedValue(ANIMAL);
prisma.animal.delete.mockResolvedValue(ANIMAL);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app).delete(`/api/animals/${ANIMAL.id}`);
expect(res.status).toBe(204);
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ action: 'DELETE' }) })
);
});
});
describe('PATCH /api/animals/:id/csv-config', () => {
const VALID_CONFIG = {
buckets: [
{ name: 'Success', type: 'note', notes: ['s'] },
{ name: 'Failure', type: 'note', notes: ['f'] },
{ name: 'Other', type: 'note', notes: [] },
{ name: 'Lick latency', type: 'numerical', notes: ['L25', 'L30'] },
],
};
it('updates csv_analysis_config and writes audit log', async () => {
prisma.animal.findUnique.mockResolvedValue(ANIMAL);
prisma.animal.update.mockResolvedValue({ ...ANIMAL, csv_analysis_config: VALID_CONFIG });
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app)
.patch(`/api/animals/${ANIMAL.id}/csv-config`)
.send(VALID_CONFIG);
expect(res.status).toBe(200);
expect(res.body.csv_analysis_config).toEqual(VALID_CONFIG);
expect(prisma.animal.update).toHaveBeenCalledWith({
where: { id: ANIMAL.id },
data: { csv_analysis_config: VALID_CONFIG },
});
});
it('returns 400 when buckets is missing', async () => {
const res = await request(app)
.patch(`/api/animals/${ANIMAL.id}/csv-config`)
.send({});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/buckets/);
});
it('returns 400 for empty bucket name', async () => {
const bad = { buckets: [...VALID_CONFIG.buckets, { name: ' ', type: 'note', notes: [] }] };
const res = await request(app)
.patch(`/api/animals/${ANIMAL.id}/csv-config`)
.send(bad);
expect(res.status).toBe(400);
});
it('returns 400 for duplicate names', async () => {
const bad = { buckets: [...VALID_CONFIG.buckets, { name: 'Success', type: 'note', notes: [] }] };
const res = await request(app)
.patch(`/api/animals/${ANIMAL.id}/csv-config`)
.send(bad);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/duplicate/i);
});
it('returns 400 for invalid type', async () => {
const bad = { buckets: [...VALID_CONFIG.buckets, { name: 'X', type: 'weird', notes: [] }] };
const res = await request(app)
.patch(`/api/animals/${ANIMAL.id}/csv-config`)
.send(bad);
expect(res.status).toBe(400);
});
it('returns 400 when missing default bucket', async () => {
const bad = { buckets: VALID_CONFIG.buckets.filter((b) => b.name !== 'Success') };
const res = await request(app)
.patch(`/api/animals/${ANIMAL.id}/csv-config`)
.send(bad);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/Success/);
});
});
+75
View File
@@ -0,0 +1,75 @@
const request = require('supertest');
jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma'));
const prisma = require('../src/lib/prisma');
const app = require('../src/app');
const LOG = {
id: 'ffffffff-0000-0000-0000-000000000001',
table_name: 'experiments',
record_id: 'aaaaaaaa-0000-0000-0000-000000000001',
action: 'UPDATE',
changes: { before: { title: 'Old' }, after: { title: 'New' } },
timestamp: new Date().toISOString(),
};
beforeEach(() => jest.clearAllMocks());
describe('GET /api/audit-logs', () => {
it('returns paginated logs', async () => {
prisma.auditLog.findMany.mockResolvedValue([LOG]);
prisma.auditLog.count.mockResolvedValue(1);
const res = await request(app).get('/api/audit-logs');
expect(res.status).toBe(200);
expect(res.body.logs).toHaveLength(1);
expect(res.body.total).toBe(1);
expect(res.body.logs[0].action).toBe('UPDATE');
});
it('filters by tableName', async () => {
prisma.auditLog.findMany.mockResolvedValue([LOG]);
prisma.auditLog.count.mockResolvedValue(1);
const res = await request(app).get('/api/audit-logs?tableName=experiments');
expect(res.status).toBe(200);
expect(prisma.auditLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { table_name: 'experiments' } })
);
});
it('returns 422 for invalid tableName (not in allowlist)', async () => {
const res = await request(app).get('/api/audit-logs?tableName=users');
expect(res.status).toBe(422);
});
it('returns 422 for invalid recordId', async () => {
const res = await request(app).get('/api/audit-logs?recordId=not-a-uuid');
expect(res.status).toBe(422);
});
it('respects limit and offset params', async () => {
prisma.auditLog.findMany.mockResolvedValue([]);
prisma.auditLog.count.mockResolvedValue(0);
const res = await request(app).get('/api/audit-logs?limit=10&offset=20');
expect(res.status).toBe(200);
expect(res.body.limit).toBe(10);
expect(res.body.offset).toBe(20);
});
});
describe('GET /api/audit-logs/:id', () => {
it('returns a single log entry', async () => {
prisma.auditLog.findUnique.mockResolvedValue(LOG);
const res = await request(app).get(`/api/audit-logs/${LOG.id}`);
expect(res.status).toBe(200);
expect(res.body.action).toBe('UPDATE');
});
it('returns 404 for unknown log id', async () => {
prisma.auditLog.findUnique.mockResolvedValue(null);
const res = await request(app).get('/api/audit-logs/00000000-0000-0000-0000-000000000000');
expect(res.status).toBe(404);
});
});
+131
View File
@@ -0,0 +1,131 @@
const request = require('supertest');
jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma'));
jest.mock('../src/lib/cache', () => ({ get: jest.fn(() => undefined), set: jest.fn(), del: jest.fn(), delByPrefix: jest.fn() }));
const prisma = require('../src/lib/prisma');
const app = require('../src/app');
const EXP_ID = 'aaaaaaaa-0000-0000-0000-000000000001';
const EXPERIMENT = {
id: EXP_ID,
title: 'Calendar Test Study',
created_at: new Date('2024-01-01').toISOString(),
};
// Helper: build a DailyStatus record
function makeStatus({ date, vitals = null, customFields = null }) {
return {
date: new Date(date),
experiment_description: null,
vitals,
treatment: null,
notes: null,
custom_fields: customFields,
};
}
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET /api/experiments/:id/calendar', () => {
it('returns 404 when experiment does not exist', async () => {
prisma.experiment.findUnique.mockResolvedValue(null);
const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar?field=vitals`);
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found/i);
});
it('returns empty days object when no statuses exist', async () => {
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
prisma.dailyStatus.findMany.mockResolvedValue([]);
const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar?field=vitals`);
expect(res.status).toBe(200);
expect(res.body.days).toEqual({});
expect(res.body.field).toBe('vitals');
});
it('counts subjects with non-empty builtin field per day', async () => {
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
prisma.dailyStatus.findMany.mockResolvedValue([
makeStatus({ date: '2026-03-10', vitals: '120/80' }),
makeStatus({ date: '2026-03-10', vitals: '110/70' }),
makeStatus({ date: '2026-03-11', vitals: '115/75' }),
makeStatus({ date: '2026-03-12', vitals: null }), // null — not counted
makeStatus({ date: '2026-03-12', vitals: '' }), // empty — not counted
]);
const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar?field=vitals`);
expect(res.status).toBe(200);
expect(res.body.days).toEqual({
'2026-03-10': 2,
'2026-03-11': 1,
});
});
it('counts subjects with non-empty custom field per day', async () => {
const FIELD_ID = 'cccccccc-0000-0000-0000-000000000099';
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
prisma.dailyStatus.findMany.mockResolvedValue([
makeStatus({ date: '2026-04-01', customFields: { [FIELD_ID]: '320' } }),
makeStatus({ date: '2026-04-01', customFields: { [FIELD_ID]: '310' } }),
makeStatus({ date: '2026-04-01', customFields: { [FIELD_ID]: null } }), // null — not counted
makeStatus({ date: '2026-04-02', customFields: { [FIELD_ID]: '315' } }),
]);
const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar?field=${FIELD_ID}`);
expect(res.status).toBe(200);
expect(res.body.days).toEqual({
'2026-04-01': 2,
'2026-04-02': 1,
});
});
it('ignores whitespace-only custom field values', async () => {
const FIELD_ID = 'dddddddd-0000-0000-0000-000000000099';
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
prisma.dailyStatus.findMany.mockResolvedValue([
makeStatus({ date: '2026-04-05', customFields: { [FIELD_ID]: ' ' } }),
makeStatus({ date: '2026-04-05', customFields: { [FIELD_ID]: '280' } }),
]);
const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar?field=${FIELD_ID}`);
expect(res.status).toBe(200);
expect(res.body.days).toEqual({ '2026-04-05': 1 });
});
it('returns days from multiple months correctly', async () => {
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
prisma.dailyStatus.findMany.mockResolvedValue([
makeStatus({ date: '2026-02-15', vitals: '100/60' }),
makeStatus({ date: '2026-03-01', vitals: '105/65' }),
makeStatus({ date: '2026-03-01', vitals: '108/68' }),
]);
const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar?field=vitals`);
expect(res.status).toBe(200);
expect(res.body.days['2026-02-15']).toBe(1);
expect(res.body.days['2026-03-01']).toBe(2);
});
it('returns field key in response body', async () => {
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
prisma.dailyStatus.findMany.mockResolvedValue([]);
const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar?field=treatment`);
expect(res.status).toBe(200);
expect(res.body.field).toBe('treatment');
});
it('handles missing field param gracefully (empty days)', async () => {
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
prisma.dailyStatus.findMany.mockResolvedValue([
makeStatus({ date: '2026-04-01', vitals: 'present' }),
]);
// No ?field= → all values undefined, nothing counted
const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar`);
expect(res.status).toBe(200);
expect(res.body.days).toEqual({});
});
});
+61
View File
@@ -0,0 +1,61 @@
const { validateCsvConfig } = require('../src/lib/csvConfigValidation');
const okBuckets = [
{ name: 'Success', type: 'note', notes: ['s'] },
{ name: 'Failure', type: 'note', notes: ['f'] },
{ name: 'Other', type: 'note', notes: [] },
];
describe('validateCsvConfig', () => {
it('accepts a valid config with the three default buckets', () => {
expect(validateCsvConfig({ buckets: okBuckets })).toBeNull();
});
it('accepts a valid config with extra note + numerical buckets', () => {
const buckets = [
...okBuckets,
{ name: 'Lick latency', type: 'numerical', notes: ['L25'] },
{ name: 'Bonus', type: 'note', notes: ['b'] },
];
expect(validateCsvConfig({ buckets })).toBeNull();
});
it('rejects when body is not an object', () => {
expect(validateCsvConfig(null)).toMatch(/object/i);
expect(validateCsvConfig('x')).toMatch(/object/i);
});
it('rejects when buckets is not an array', () => {
expect(validateCsvConfig({ buckets: 'nope' })).toMatch(/array/i);
});
it('rejects an empty bucket name', () => {
const buckets = [...okBuckets, { name: ' ', type: 'note', notes: [] }];
expect(validateCsvConfig({ buckets })).toMatch(/name/i);
});
it('rejects duplicate bucket names', () => {
const buckets = [...okBuckets, { name: 'Success', type: 'note', notes: [] }];
expect(validateCsvConfig({ buckets })).toMatch(/duplicate/i);
});
it('rejects an invalid type value', () => {
const buckets = [...okBuckets, { name: 'X', type: 'weird', notes: [] }];
expect(validateCsvConfig({ buckets })).toMatch(/type/i);
});
it('rejects when notes is not an array', () => {
const buckets = [...okBuckets, { name: 'X', type: 'note', notes: 'oops' }];
expect(validateCsvConfig({ buckets })).toMatch(/notes/i);
});
it('rejects when notes contains non-strings', () => {
const buckets = [...okBuckets, { name: 'X', type: 'note', notes: [1, 2] }];
expect(validateCsvConfig({ buckets })).toMatch(/notes/i);
});
it('rejects when a default bucket is missing', () => {
const buckets = okBuckets.filter((b) => b.name !== 'Success');
expect(validateCsvConfig({ buckets })).toMatch(/Success/);
});
});
+133
View File
@@ -0,0 +1,133 @@
const request = require('supertest');
jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma'));
const prisma = require('../src/lib/prisma');
const app = require('../src/app');
const ANIMAL_ID = 'dddddddd-0000-0000-0000-000000000001';
const STATUS = {
id: 'eeeeeeee-0000-0000-0000-000000000001',
animal_id: ANIMAL_ID,
date: new Date('2024-06-01').toISOString(),
experiment_description: 'Baseline measurement',
vitals: 'HR 72, Temp 37.2',
treatment: 'Control',
notes: 'Healthy',
};
beforeEach(() => jest.clearAllMocks());
describe('GET /api/daily-statuses', () => {
it('returns statuses filtered by animalId', async () => {
prisma.dailyStatus.findMany.mockResolvedValue([STATUS]);
const res = await request(app).get(`/api/daily-statuses?animalId=${ANIMAL_ID}`);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].vitals).toBe('HR 72, Temp 37.2');
});
it('returns 422 for invalid animalId UUID', async () => {
const res = await request(app).get('/api/daily-statuses?animalId=invalid');
expect(res.status).toBe(422);
});
});
describe('GET /api/daily-statuses/:id', () => {
it('returns a single status entry', async () => {
prisma.dailyStatus.findUnique.mockResolvedValue({ ...STATUS, animal: {} });
const res = await request(app).get(`/api/daily-statuses/${STATUS.id}`);
expect(res.status).toBe(200);
expect(res.body.id).toBe(STATUS.id);
});
it('returns 404 for unknown id', async () => {
prisma.dailyStatus.findUnique.mockResolvedValue(null);
const res = await request(app).get('/api/daily-statuses/00000000-0000-0000-0000-000000000000');
expect(res.status).toBe(404);
});
});
describe('POST /api/daily-statuses', () => {
it('creates status and returns 201 with UUID', async () => {
prisma.dailyStatus.create.mockResolvedValue(STATUS);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app)
.post('/api/daily-statuses')
.send({ animal_id: ANIMAL_ID, date: '2024-06-01', vitals: 'HR 72' });
expect(res.status).toBe(201);
expect(res.body.id).toMatch(/^[0-9a-f-]{36}$/i);
expect(prisma.auditLog.create).toHaveBeenCalled();
});
it('returns 422 when animal_id is missing', async () => {
const res = await request(app)
.post('/api/daily-statuses')
.send({ date: '2024-06-01' });
expect(res.status).toBe(422);
expect(res.body.details).toEqual(
expect.arrayContaining([expect.objectContaining({ field: 'animal_id' })])
);
});
it('returns 422 when date is missing', async () => {
const res = await request(app)
.post('/api/daily-statuses')
.send({ animal_id: ANIMAL_ID });
expect(res.status).toBe(422);
expect(res.body.details).toEqual(
expect.arrayContaining([expect.objectContaining({ field: 'date' })])
);
});
it('returns 422 when date is not a valid ISO date', async () => {
const res = await request(app)
.post('/api/daily-statuses')
.send({ animal_id: ANIMAL_ID, date: 'not-a-date' });
expect(res.status).toBe(422);
});
});
describe('PUT /api/daily-statuses/:id', () => {
it('updates status and writes UPDATE audit log', async () => {
const updated = { ...STATUS, vitals: 'HR 80' };
prisma.dailyStatus.findUnique
.mockResolvedValueOnce(STATUS)
.mockResolvedValueOnce(updated);
prisma.dailyStatus.update.mockResolvedValue(updated);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app)
.put(`/api/daily-statuses/${STATUS.id}`)
.send({ vitals: 'HR 80' });
expect(res.status).toBe(200);
expect(res.body.vitals).toBe('HR 80');
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ action: 'UPDATE' }) })
);
});
it('returns 422 when date format is invalid', async () => {
prisma.dailyStatus.findUnique.mockResolvedValue(STATUS);
const res = await request(app)
.put(`/api/daily-statuses/${STATUS.id}`)
.send({ date: 'garbage' });
expect(res.status).toBe(422);
});
});
describe('DELETE /api/daily-statuses/:id', () => {
it('deletes status and writes DELETE audit log', async () => {
prisma.dailyStatus.findUnique.mockResolvedValue(STATUS);
prisma.dailyStatus.delete.mockResolvedValue(STATUS);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app).delete(`/api/daily-statuses/${STATUS.id}`);
expect(res.status).toBe(204);
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ action: 'DELETE' }) })
);
});
});
@@ -0,0 +1,166 @@
const request = require('supertest');
jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma'));
// Always miss the cache in unit tests so each test exercises the real route logic.
jest.mock('../src/lib/cache', () => ({ get: jest.fn(() => undefined), set: jest.fn(), del: jest.fn(), delByPrefix: jest.fn() }));
const prisma = require('../src/lib/prisma');
const app = require('../src/app');
const EXP_ID = 'aaaaaaaa-0000-0000-0000-000000000001';
const EXPERIMENT = {
id: EXP_ID,
title: 'DS Test Study',
created_at: new Date('2024-01-01').toISOString(),
};
function makeStatus(overrides = {}) {
return {
id: overrides.id ?? 'dddddddd-0000-0000-0000-000000000001',
animal_id: overrides.animalId ?? 'bbbbbbbb-0000-0000-0000-000000000001',
date: new Date(overrides.date ?? '2026-04-01'),
analysis_summary: overrides.analysis_summary ?? null,
experiment_description: overrides.experiment_description ?? null,
vitals: overrides.vitals ?? null,
treatment: overrides.treatment ?? null,
notes: overrides.notes ?? null,
custom_fields: overrides.custom_fields ?? {},
};
}
beforeEach(() => {
jest.clearAllMocks();
});
// ── GET /api/experiments/:id/daily-statuses ────────────────────────────────────
describe('GET /api/experiments/:id/daily-statuses', () => {
it('returns 404 when experiment does not exist', async () => {
prisma.experiment.findUnique.mockResolvedValue(null);
const res = await request(app).get(`/api/experiments/${EXP_ID}/daily-statuses`);
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found/i);
});
it('returns all statuses when no filters are provided', async () => {
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
const statuses = [
makeStatus({ id: 'id-1', analysis_summary: { total: 10, counts: { Success: 8 }, success_rate: 0.8 } }),
makeStatus({ id: 'id-2', analysis_summary: null }),
makeStatus({ id: 'id-3', analysis_summary: null }),
];
prisma.dailyStatus.findMany.mockResolvedValue(statuses);
const res = await request(app).get(`/api/experiments/${EXP_ID}/daily-statuses`);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(3);
});
// ── analysisOnly filter — this is the scenario that caused a 500 ──────────
it('analysisOnly=true returns only statuses with non-null analysis_summary', async () => {
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
const summary = { total: 20, counts: { Success: 15, Failure: 5 }, success_rate: 0.75 };
prisma.dailyStatus.findMany.mockResolvedValue([
makeStatus({ id: 'id-1', analysis_summary: summary }),
makeStatus({ id: 'id-2', analysis_summary: null }), // must be excluded
makeStatus({ id: 'id-3', analysis_summary: null }), // must be excluded
makeStatus({ id: 'id-4', analysis_summary: summary }),
]);
const res = await request(app)
.get(`/api/experiments/${EXP_ID}/daily-statuses?analysisOnly=true`);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
expect(res.body.every((s) => s.analysis_summary !== null)).toBe(true);
expect(res.body.map((s) => s.id)).toEqual(['id-1', 'id-4']);
});
it('analysisOnly=true returns empty array when no statuses have analysis_summary', async () => {
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
prisma.dailyStatus.findMany.mockResolvedValue([
makeStatus({ id: 'id-1', analysis_summary: null }),
makeStatus({ id: 'id-2', analysis_summary: null }),
]);
const res = await request(app)
.get(`/api/experiments/${EXP_ID}/daily-statuses?analysisOnly=true`);
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it('analysisOnly=true does not 500 when Prisma returns mixed null / object values', async () => {
// This is the exact shape that caused the production 500 — the fix must not
// rely on a Prisma WHERE clause (which errors on Json? null filtering in v5).
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
prisma.dailyStatus.findMany.mockResolvedValue([
makeStatus({ id: 'id-null', analysis_summary: null }),
makeStatus({ id: 'id-value', analysis_summary: { total: 5, counts: {}, success_rate: null } }),
]);
const res = await request(app)
.get(`/api/experiments/${EXP_ID}/daily-statuses?analysisOnly=true`);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].id).toBe('id-value');
});
it('analysisOnly=false (explicit) returns all statuses', async () => {
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
const statuses = [
makeStatus({ id: 'id-1', analysis_summary: { total: 1, counts: {}, success_rate: null } }),
makeStatus({ id: 'id-2', analysis_summary: null }),
];
prisma.dailyStatus.findMany.mockResolvedValue(statuses);
const res = await request(app)
.get(`/api/experiments/${EXP_ID}/daily-statuses?analysisOnly=false`);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
});
// ── date filter ────────────────────────────────────────────────────────────
it('passes date range to Prisma when ?date= is provided', async () => {
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
prisma.dailyStatus.findMany.mockResolvedValue([]);
await request(app)
.get(`/api/experiments/${EXP_ID}/daily-statuses?date=2026-04-23`);
const whereArg = prisma.dailyStatus.findMany.mock.calls[0][0].where;
expect(whereArg.date).toBeDefined();
expect(whereArg.date.gte).toEqual(new Date('2026-04-23T00:00:00.000Z'));
});
it('returns 200 with empty array when no statuses exist', async () => {
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
prisma.dailyStatus.findMany.mockResolvedValue([]);
const res = await request(app).get(`/api/experiments/${EXP_ID}/daily-statuses`);
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it('returned records include the expected fields', async () => {
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
const summary = { total: 3, counts: { Success: 2 }, success_rate: 0.67 };
prisma.dailyStatus.findMany.mockResolvedValue([
makeStatus({ id: 'id-1', vitals: '120/80', analysis_summary: summary }),
]);
const res = await request(app).get(`/api/experiments/${EXP_ID}/daily-statuses`);
expect(res.status).toBe(200);
const rec = res.body[0];
expect(rec).toMatchObject({
id: 'id-1',
vitals: '120/80',
analysis_summary: summary,
});
});
});
+184
View File
@@ -0,0 +1,184 @@
const request = require('supertest');
// Mock Prisma before importing app so the app uses the mock
jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma'));
const prisma = require('../src/lib/prisma');
const app = require('../src/app');
const EXPERIMENT = {
id: 'aaaaaaaa-0000-0000-0000-000000000001',
title: 'Test Study',
created_at: new Date('2024-01-01').toISOString(),
_count: { animals: 0 },
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET /api/experiments', () => {
it('returns list of experiments', async () => {
prisma.experiment.findMany.mockResolvedValue([EXPERIMENT]);
const res = await request(app).get('/api/experiments');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].title).toBe('Test Study');
});
it('returns empty array when no experiments', async () => {
prisma.experiment.findMany.mockResolvedValue([]);
const res = await request(app).get('/api/experiments');
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
});
describe('GET /api/experiments/:id', () => {
it('returns single experiment', async () => {
prisma.experiment.findUnique.mockResolvedValue({ ...EXPERIMENT, animals: [] });
const res = await request(app).get(`/api/experiments/${EXPERIMENT.id}`);
expect(res.status).toBe(200);
expect(res.body.id).toBe(EXPERIMENT.id);
});
it('returns 404 for unknown id', async () => {
prisma.experiment.findUnique.mockResolvedValue(null);
const res = await request(app).get('/api/experiments/00000000-0000-0000-0000-000000000000');
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found/i);
});
});
describe('POST /api/experiments', () => {
it('creates experiment and returns 201 with UUID', async () => {
prisma.experiment.create.mockResolvedValue(EXPERIMENT);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app)
.post('/api/experiments')
.send({ title: 'Test Study' });
expect(res.status).toBe(201);
expect(res.body.id).toBe(EXPERIMENT.id);
expect(res.body.title).toBe('Test Study');
// UUID format assertion
expect(res.body.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
);
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ action: 'CREATE' }) })
);
});
it('returns 422 when title is missing', async () => {
const res = await request(app).post('/api/experiments').send({});
expect(res.status).toBe(422);
expect(res.body.details).toEqual(
expect.arrayContaining([expect.objectContaining({ field: 'title' })])
);
});
it('returns 422 when title exceeds 255 chars', async () => {
const res = await request(app)
.post('/api/experiments')
.send({ title: 'x'.repeat(256) });
expect(res.status).toBe(422);
});
});
describe('PUT /api/experiments/:id', () => {
it('updates experiment and writes audit log', async () => {
const updated = { ...EXPERIMENT, title: 'Renamed Study' };
prisma.experiment.findUnique
.mockResolvedValueOnce(EXPERIMENT) // audit middleware fetches before-state
.mockResolvedValueOnce(updated); // audit middleware fetches after-state
prisma.experiment.update.mockResolvedValue(updated);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app)
.put(`/api/experiments/${EXPERIMENT.id}`)
.send({ title: 'Renamed Study' });
expect(res.status).toBe(200);
expect(res.body.title).toBe('Renamed Study');
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ action: 'UPDATE' }) })
);
});
it('returns 422 with empty title', async () => {
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
const res = await request(app)
.put(`/api/experiments/${EXPERIMENT.id}`)
.send({ title: '' });
expect(res.status).toBe(422);
});
});
describe('DELETE /api/experiments/:id', () => {
it('deletes experiment and writes DELETE audit log', async () => {
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
prisma.experiment.delete.mockResolvedValue(EXPERIMENT);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app).delete(`/api/experiments/${EXPERIMENT.id}`);
expect(res.status).toBe(204);
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ action: 'DELETE' }) })
);
});
});
describe('GET /health', () => {
it('returns ok status', async () => {
const res = await request(app).get('/health');
expect(res.status).toBe(200);
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(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();
});
});
+6
View File
@@ -0,0 +1,6 @@
// Global setup — runs once before all test suites.
// When DATABASE_URL points to a real test DB, Prisma migrations are applied here.
// In CI/local runs without a live DB, tests use mocked Prisma via jest.mock.
module.exports = async function () {
process.env.NODE_ENV = 'test';
};
+3
View File
@@ -0,0 +1,3 @@
module.exports = async function () {
// Graceful shutdown of any open handles
};
+13 -5
View File
@@ -9,10 +9,13 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-expuser}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-exppassword}
POSTGRES_DB: ${POSTGRES_DB:-experiments_db}
TZ: America/New_York
command: ["postgres", "-c", "timezone=America/New_York"]
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
# Port NOT exposed to host — only reachable by backend via internal Docker network
expose:
- "5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-expuser} -d ${POSTGRES_DB:-experiments_db}"]
interval: 10s
@@ -32,13 +35,18 @@ services:
DATABASE_URL: postgresql://${POSTGRES_USER:-expuser}:${POSTGRES_PASSWORD:-exppassword}@postgres:5432/${POSTGRES_DB:-experiments_db}
NODE_ENV: production
PORT: 3001
CORS_ORIGIN: "http://localhost:52867"
TZ: America/New_York
# Non-root user set in Dockerfile; entrypoint runs migrations then starts server
entrypoint: ["/bin/sh", "/app/docker-entrypoint.sh"]
ports:
- "3001:3001"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/health || exit 1"]
interval: 15s
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/health || exit 1"]
interval: 10s
timeout: 5s
retries: 5
retries: 10
start_period: 40s
frontend:
build:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,628 @@
# Cross-subject Plot Interactions 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:** Add a subject-visibility sidebar, legend click-to-toggle, value-ordered tooltips, and legend/sidebar hover highlight to the "Cross-subject metrics" plot.
**Architecture:** Extract pure interaction logic into `src/lib/crossSubjectChart.js` (unit-tested), build a standalone presentational `SubjectSidebar` component (unit-tested with RTL), then wire shared client-side state (`hiddenSubjects`, `highlightedSubject`, `sidebarOpen`) into `ExperimentAnalysisCharts` and thread it through both Recharts `LineChart`s. Visibility is global per-subject (affects both charts).
**Tech Stack:** React 18, Recharts 2.15, Jest + React Testing Library, Tailwind.
**Constraints:** Frontend only — no backend/API/DB changes, nothing persisted. Do not alter the sibling `SubjectAnalysisCharts` or the existing controls (X axis, Color by, tick step, per-chart customize bars, Y-metric selector).
---
### Task 1: Pure interaction helpers
**Files:**
- Create: `frontend/src/lib/crossSubjectChart.js`
- Test: `frontend/tests/crossSubjectChart.test.js`
- [ ] **Step 1: Write the failing tests**
```js
// frontend/tests/crossSubjectChart.test.js
import {
sortPayloadByValueDesc,
toggleInSet,
setIdsHidden,
groupVisibilityState,
groupLines,
} from '../src/lib/crossSubjectChart';
describe('sortPayloadByValueDesc', () => {
it('sorts by value descending and drops null/undefined values', () => {
const payload = [
{ dataKey: 'a', value: 40 },
{ dataKey: 'b', value: null },
{ dataKey: 'c', value: 92 },
{ dataKey: 'd', value: 78 },
];
expect(sortPayloadByValueDesc(payload).map((p) => p.dataKey)).toEqual(['c', 'd', 'a']);
});
it('returns [] for non-array input', () => {
expect(sortPayloadByValueDesc(undefined)).toEqual([]);
});
it('does not mutate the input array', () => {
const payload = [{ dataKey: 'a', value: 1 }, { dataKey: 'b', value: 2 }];
sortPayloadByValueDesc(payload);
expect(payload.map((p) => p.dataKey)).toEqual(['a', 'b']);
});
});
describe('toggleInSet', () => {
it('adds a missing id and removes a present id, returning a new Set', () => {
const a = new Set(['x']);
const added = toggleInSet(a, 'y');
expect([...added].sort()).toEqual(['x', 'y']);
expect(added).not.toBe(a);
const removed = toggleInSet(added, 'x');
expect([...removed]).toEqual(['y']);
});
});
describe('setIdsHidden', () => {
it('adds all ids when hidden=true', () => {
expect([...setIdsHidden(new Set(['a']), ['b', 'c'], true)].sort()).toEqual(['a', 'b', 'c']);
});
it('removes all ids when hidden=false', () => {
expect([...setIdsHidden(new Set(['a', 'b', 'c']), ['b', 'c'], false)]).toEqual(['a']);
});
it('returns a new Set', () => {
const s = new Set();
expect(setIdsHidden(s, ['a'], true)).not.toBe(s);
});
});
describe('groupVisibilityState', () => {
it("returns 'all' when none of the ids are hidden", () => {
expect(groupVisibilityState(['a', 'b'], new Set())).toBe('all');
});
it("returns 'none' when all ids are hidden", () => {
expect(groupVisibilityState(['a', 'b'], new Set(['a', 'b']))).toBe('none');
});
it("returns 'some' when a subset is hidden", () => {
expect(groupVisibilityState(['a', 'b'], new Set(['a']))).toBe('some');
});
it("returns 'none' for an empty group", () => {
expect(groupVisibilityState([], new Set())).toBe('none');
});
});
describe('groupLines', () => {
it('groups by .group preserving first-seen group order and within-group order', () => {
const lines = [
{ id: '1', group: 'B' },
{ id: '2', group: 'A' },
{ id: '3', group: 'B' },
];
const out = groupLines(lines);
expect(out.map((g) => g.group)).toEqual(['B', 'A']);
expect(out[0].subjects.map((s) => s.id)).toEqual(['1', '3']);
});
it("uses '—' for lines with no group", () => {
expect(groupLines([{ id: '1' }])[0].group).toBe('—');
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd frontend && npx jest tests/crossSubjectChart.test.js`
Expected: FAIL — cannot find module `../src/lib/crossSubjectChart`.
- [ ] **Step 3: Write the implementation**
```js
// frontend/src/lib/crossSubjectChart.js
// Pure helpers for the cross-subject metrics plot interactions. No React, no I/O.
// Sort a Recharts tooltip payload by numeric value descending, dropping entries
// whose value is null/undefined. Returns a NEW array (does not mutate input).
export function sortPayloadByValueDesc(payload) {
if (!Array.isArray(payload)) return [];
return payload
.filter((p) => p && p.value != null)
.slice()
.sort((a, b) => b.value - a.value);
}
// Toggle an id's membership in a Set, returning a NEW Set.
export function toggleInSet(set, id) {
const next = new Set(set);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
}
// Add (hidden=true) or remove (hidden=false) a list of ids from a hidden-set.
// Returns a NEW Set.
export function setIdsHidden(set, ids, hidden) {
const next = new Set(set);
for (const id of ids) {
if (hidden) next.add(id);
else next.delete(id);
}
return next;
}
// Tri-state for a group checkbox given which subject ids are hidden.
// 'all' = all visible, 'none' = all hidden, 'some' = mixed.
export function groupVisibilityState(subjectIds, hiddenSet) {
if (subjectIds.length === 0) return 'none';
const hiddenCount = subjectIds.filter((id) => hiddenSet.has(id)).length;
if (hiddenCount === 0) return 'all';
if (hiddenCount === subjectIds.length) return 'none';
return 'some';
}
// Group chart "lines" by their .group field, preserving first-seen group order
// and line order within each group. Returns [{ group, subjects: [line...] }].
export function groupLines(lines) {
const order = [];
const byGroup = new Map();
for (const line of lines) {
const g = line.group ?? '—';
if (!byGroup.has(g)) { byGroup.set(g, []); order.push(g); }
byGroup.get(g).push(line);
}
return order.map((group) => ({ group, subjects: byGroup.get(group) }));
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd frontend && npx jest tests/crossSubjectChart.test.js`
Expected: PASS — all describe blocks green.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/lib/crossSubjectChart.js frontend/tests/crossSubjectChart.test.js
git commit -m "feat(frontend): pure helpers for cross-subject plot interactions"
```
---
### Task 2: SubjectSidebar component
**Files:**
- Create: `frontend/src/components/SubjectSidebar.jsx`
- Test: `frontend/tests/SubjectSidebar.test.jsx`
- [ ] **Step 1: Write the failing tests**
```jsx
// frontend/tests/SubjectSidebar.test.jsx
import React from 'react';
import { render, screen, fireEvent, within } from '@testing-library/react';
import { SubjectSidebar } from '../src/components/SubjectSidebar';
const LINES = [
{ id: 'a1', name: 'Mouse-A1', group: 'Group A', color: '#111' },
{ id: 'a2', name: 'Mouse-A2', group: 'Group A', color: '#222' },
{ id: 'b1', name: 'Mouse-B1', group: 'Group B', color: '#333' },
];
function setup(overrides = {}) {
const props = {
lines: LINES,
hiddenSubjects: new Set(),
onToggleSubject: jest.fn(),
onToggleGroup: jest.fn(),
onShowAll: jest.fn(),
onHighlight: jest.fn(),
...overrides,
};
render(<SubjectSidebar {...props} />);
return props;
}
it('renders each group header with its subject count', () => {
setup();
expect(screen.getByText('Group A')).toBeInTheDocument();
expect(screen.getByText('Group B')).toBeInTheDocument();
expect(screen.getByText('(2)')).toBeInTheDocument();
expect(screen.getByText('(1)')).toBeInTheDocument();
});
it('renders a subject checkbox per subject, checked when visible', () => {
setup({ hiddenSubjects: new Set(['a2']) });
expect(screen.getByLabelText('Mouse-A1').checked).toBe(true);
expect(screen.getByLabelText('Mouse-A2').checked).toBe(false);
});
it('calls onToggleSubject with the id when a subject checkbox is clicked', () => {
const { onToggleSubject } = setup();
fireEvent.click(screen.getByLabelText('Mouse-A1'));
expect(onToggleSubject).toHaveBeenCalledWith('a1');
});
it('shows the group checkbox as indeterminate when the group is mixed', () => {
setup({ hiddenSubjects: new Set(['a1']) });
const groupCheckbox = screen.getByLabelText(/Group A/);
expect(groupCheckbox.indeterminate).toBe(true);
expect(groupCheckbox.checked).toBe(false);
});
it('checks the group checkbox when all members are visible', () => {
setup();
expect(screen.getByLabelText(/Group A/).checked).toBe(true);
});
it('calls onToggleGroup with the group ids and hidden=true when an all-visible group is clicked', () => {
const { onToggleGroup } = setup();
fireEvent.click(screen.getByLabelText(/Group A/));
expect(onToggleGroup).toHaveBeenCalledWith(['a1', 'a2'], true);
});
it('hides the Show all link when nothing is hidden, shows it otherwise', () => {
const { rerender } = render(
<SubjectSidebar lines={LINES} hiddenSubjects={new Set()}
onToggleSubject={() => {}} onToggleGroup={() => {}}
onShowAll={() => {}} onHighlight={() => {}} />,
);
expect(screen.queryByText('Show all')).toBeNull();
rerender(
<SubjectSidebar lines={LINES} hiddenSubjects={new Set(['a1'])}
onToggleSubject={() => {}} onToggleGroup={() => {}}
onShowAll={() => {}} onHighlight={() => {}} />,
);
expect(screen.getByText('Show all')).toBeInTheDocument();
});
it('calls onShowAll when the Show all link is clicked', () => {
const { onShowAll } = setup({ hiddenSubjects: new Set(['a1']) });
fireEvent.click(screen.getByText('Show all'));
expect(onShowAll).toHaveBeenCalled();
});
it('calls onHighlight on subject row hover enter/leave', () => {
const { onHighlight } = setup();
const row = screen.getByLabelText('Mouse-A1').closest('label');
fireEvent.mouseEnter(row);
expect(onHighlight).toHaveBeenCalledWith('a1');
fireEvent.mouseLeave(row);
expect(onHighlight).toHaveBeenCalledWith(null);
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd frontend && npx jest tests/SubjectSidebar.test.jsx`
Expected: FAIL — cannot find module `../src/components/SubjectSidebar`.
- [ ] **Step 3: Write the implementation**
Note: the group checkbox's accessible name comes from the group `<span>` text inside its `<label>`, so `getByLabelText(/Group A/)` matches it; subject checkboxes are matched by their name text. The group `<label>` text includes the count, so its accessible name is e.g. "Group A (2)" — `/Group A/` regex still matches.
```jsx
// frontend/src/components/SubjectSidebar.jsx
import React from 'react';
import { groupLines, groupVisibilityState } from '../lib/crossSubjectChart';
// Presentational panel listing subjects grouped by their group, with per-subject
// and per-group visibility checkboxes. All state lives in the parent; this component
// only renders and calls handlers.
export function SubjectSidebar({
lines, hiddenSubjects, onToggleSubject, onToggleGroup, onShowAll, onHighlight,
}) {
const groups = groupLines(lines);
const anyHidden = lines.some((l) => hiddenSubjects.has(l.id));
return (
<div className="w-[210px] shrink-0 border-l border-gray-100 pl-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Subjects</span>
{anyHidden && (
<button type="button" onClick={onShowAll}
className="text-xs text-indigo-500 hover:text-indigo-700">Show all</button>
)}
</div>
<div className="space-y-3 max-h-[320px] overflow-y-auto pr-1">
{groups.map(({ group, subjects }) => {
const ids = subjects.map((s) => s.id);
const state = groupVisibilityState(ids, hiddenSubjects);
return (
<div key={group}>
<label className="flex items-center gap-1.5 text-xs font-medium text-gray-600 cursor-pointer">
<input type="checkbox"
checked={state === 'all'}
ref={(el) => { if (el) el.indeterminate = state === 'some'; }}
onChange={() => onToggleGroup(ids, state === 'all')}
className="accent-indigo-500" />
<span className="truncate">{group}</span>
<span className="text-gray-400">({subjects.length})</span>
</label>
<div className="mt-1 space-y-0.5 pl-1">
{subjects.map((s) => (
<label key={s.id}
onMouseEnter={() => onHighlight(s.id)}
onMouseLeave={() => onHighlight(null)}
className="flex items-center gap-1.5 text-xs text-gray-600 cursor-pointer hover:bg-gray-50 rounded px-1 py-0.5">
<input type="checkbox"
checked={!hiddenSubjects.has(s.id)}
onChange={() => onToggleSubject(s.id)}
className="accent-indigo-500" />
<span className="inline-block w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: s.color }} />
<span className="truncate">{s.name}</span>
</label>
))}
</div>
</div>
);
})}
</div>
</div>
);
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd frontend && npx jest tests/SubjectSidebar.test.jsx`
Expected: PASS — all tests green.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/components/SubjectSidebar.jsx frontend/tests/SubjectSidebar.test.jsx
git commit -m "feat(frontend): add SubjectSidebar visibility panel"
```
---
### Task 3: Wire state, sidebar, and behaviors into ExperimentAnalysisCharts
**Files:**
- Modify: `frontend/src/components/AnalysisCharts.jsx` (`ExperimentAnalysisCharts`, lines ~446641; imports at top)
- Test: `frontend/tests/ExperimentAnalysisCharts.test.jsx` (create)
- [ ] **Step 1: Add imports**
At the top of `frontend/src/components/AnalysisCharts.jsx`, add after the existing `date-fns` import:
```jsx
import { SubjectSidebar } from './SubjectSidebar';
import { sortPayloadByValueDesc, toggleInSet, setIdsHidden } from '../lib/crossSubjectChart';
```
- [ ] **Step 2: Add shared state and handlers**
In `ExperimentAnalysisCharts`, immediately after the existing `const [tickStep, setTickStep] = useState(1);` line (~454), add:
```jsx
// 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);
const toggleHidden = (id) => setHiddenSubjects((prev) => toggleInSet(prev, id));
const toggleGroup = (ids, hidden) => setHiddenSubjects((prev) => setIdsHidden(prev, ids, hidden));
const showAll = () => setHiddenSubjects(new Set());
```
- [ ] **Step 3: Update the `ExpTooltip` to order rows by value descending**
Replace the existing `ExpTooltip` function (~518533) with:
```jsx
function ExpTooltip({ active, payload, label, unit = '' }) {
if (!active || !payload?.length) return null;
const rows = sortPayloadByValueDesc(payload);
return (
<div className="bg-white border border-gray-200 rounded shadow-sm px-3 py-2 text-xs space-y-0.5 max-h-48 overflow-y-auto">
<p className="font-semibold text-gray-700 mb-1">{label}</p>
{rows.map((p) => {
const line = lines.find((l) => l.id === p.dataKey);
return (
<p key={p.dataKey} style={{ color: p.stroke }}>
{line?.name ?? p.dataKey}{line?.group && line.group !== '—' ? ` (${line.group})` : ''}: {p.value}{unit}
</p>
);
})}
</div>
);
}
```
- [ ] **Step 4: Add the sidebar toggle button to the global controls row**
In the global-controls `<div className="flex items-center flex-wrap gap-3">` block, immediately after the `<TickStepControl ... />` line (~556), add:
```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>
)}
```
Note: `hasData` is declared at ~509 (`const hasData = lines.length > 0;`), before the `return`, so it is in scope here.
- [ ] **Step 5: Wrap the charts in a flex row with the sidebar**
Replace the `{hasData && (` fragment opener and its closing for the charts block. The current structure is:
```jsx
{hasData && (
<>
{/* Chart 1 — counts */}
<div> ... </div>
{/* Chart 2 — success rate */}
<div> ... </div>
</>
)}
```
Change ONLY the wrapper (keep both chart `<div>` blocks exactly as they are between the markers):
```jsx
{hasData && (
<div className="flex gap-3 items-start">
<div className="flex-1 min-w-0 space-y-5">
{/* Chart 1 — counts */}
{/* ...unchanged chart 1 <div> ... */}
{/* Chart 2 — success rate */}
{/* ...unchanged chart 2 <div> ... */}
</div>
{sidebarOpen && (
<SubjectSidebar
lines={lines}
hiddenSubjects={hiddenSubjects}
onToggleSubject={toggleHidden}
onToggleGroup={toggleGroup}
onShowAll={showAll}
onHighlight={setHighlightedSubject}
/>
)}
</div>
)}
```
`min-w-0` on the chart column lets the flex child shrink so `ResponsiveContainer` re-flows when the sidebar is open.
- [ ] **Step 6: Add legend handlers and per-line hide/highlight to BOTH charts**
In the **counts** chart, replace the `<Legend ... />` (~602603) with:
```jsx
<Legend wrapperStyle={{ fontSize: 11 }}
formatter={(value) => lines.find((l) => l.id === value)?.name ?? value}
onClick={(o) => toggleHidden(o.dataKey)}
onMouseEnter={(o) => setHighlightedSubject(o.dataKey)}
onMouseLeave={() => setHighlightedSubject(null)} />
```
and replace its `lines.map(...)` `<Line>` block (~604608) with:
```jsx
{lines.map((l) => (
<Line key={l.id} type="monotone" dataKey={l.id} name={l.id}
stroke={l.color}
strokeWidth={highlightedSubject === l.id ? 3.5 : 2}
hide={hiddenSubjects.has(l.id)}
dot={{ r: 3 }} activeDot={{ r: 5 }}
connectNulls isAnimationActive={false} />
))}
```
In the **success-rate** chart, make the identical replacements for its `<Legend ... />` (~627628) and its `lines.map(...)` `<Line>` block (~629633) — same two snippets above.
- [ ] **Step 7: Write a smoke test for the wiring**
```jsx
// frontend/tests/ExperimentAnalysisCharts.test.jsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
// recharts uses ResizeObserver (absent in jsdom); stub all used pieces as passthroughs.
jest.mock('recharts', () => {
const Pass = ({ children }) => <div>{children}</div>;
return {
ResponsiveContainer: Pass, LineChart: Pass, Line: () => null,
XAxis: () => null, YAxis: () => null, CartesianGrid: () => null,
Tooltip: () => null, Legend: () => null,
};
});
import { ExperimentAnalysisCharts } from '../src/components/AnalysisCharts';
const ANIMALS = [
{ id: 'a1', animal_name: 'Mouse-A1', subject_info: { grp: 'Group A' } },
{ id: 'b1', animal_name: 'Mouse-B1', subject_info: { grp: 'Group B' } },
];
const STATUSES = [
{ animal_id: 'a1', date: '2026-01-01', analysis_summary: { total: 10, success_rate: 0.5, counts: { Success: 5 } } },
{ animal_id: 'b1', date: '2026-01-01', analysis_summary: { total: 8, success_rate: 0.25, counts: { Success: 2 } } },
];
const SUBJECT_TEMPLATE = [{ fieldId: 'grp', label: 'Group', active: true }];
const DAILY_TEMPLATE = [];
function renderChart() {
render(
<ExperimentAnalysisCharts
animals={ANIMALS}
experimentStatuses={STATUSES}
subjectTemplate={SUBJECT_TEMPLATE}
dailyTemplate={DAILY_TEMPLATE}
/>,
);
}
it('toggles the subject sidebar open and closed', () => {
renderChart();
expect(screen.queryByText('Subjects')).toBeNull(); // closed: panel header not rendered
fireEvent.click(screen.getByText('▸ Subjects'));
// panel header "Subjects" + both subjects now visible
expect(screen.getByText('Subjects')).toBeInTheDocument();
expect(screen.getByLabelText('Mouse-A1')).toBeInTheDocument();
expect(screen.getByLabelText('Mouse-B1')).toBeInTheDocument();
fireEvent.click(screen.getByText('◂ Subjects'));
expect(screen.queryByText('Subjects')).toBeNull();
});
it('unchecking a subject in the sidebar persists across re-render', () => {
renderChart();
fireEvent.click(screen.getByText('▸ Subjects'));
const cb = screen.getByLabelText('Mouse-A1');
expect(cb.checked).toBe(true);
fireEvent.click(cb);
expect(screen.getByLabelText('Mouse-A1').checked).toBe(false);
// hiding one subject reveals the Show all link
expect(screen.getByText('Show all')).toBeInTheDocument();
});
```
Note: the controls header `<h2>` text is "Cross-subject metrics"; the sidebar panel header text is "Subjects". They are distinct, so `queryByText('Subjects')` is null until the panel opens.
- [ ] **Step 8: Run the smoke test to verify it passes**
Run: `cd frontend && npx jest tests/ExperimentAnalysisCharts.test.jsx`
Expected: PASS — both tests green.
- [ ] **Step 9: Run the full frontend test suite to confirm no regressions**
Run: `cd frontend && npm test`
Expected: PASS — all existing suites plus the three new files green.
- [ ] **Step 10: Build to confirm no syntax/compile errors**
Run: `cd frontend && npm run build`
Expected: Vite build succeeds with no errors.
- [ ] **Step 11: Commit**
```bash
git add frontend/src/components/AnalysisCharts.jsx frontend/tests/ExperimentAnalysisCharts.test.jsx
git commit -m "feat(frontend): subject sidebar, legend toggle/highlight, ordered tooltip on cross-subject plot"
```
---
## Manual Verification (after Task 3)
On the `ExperimentDetail` page for an experiment with animals and saved analysis metrics:
- [ ] `▸ Subjects` button toggles the sidebar; charts re-flow narrower while open.
- [ ] Unchecking a subject removes its line from BOTH charts; its legend entry greys out and stays clickable.
- [ ] Re-checking (sidebar) or clicking the greyed legend entry restores the line in both charts.
- [ ] Group checkbox toggles all members; shows indeterminate () when the group is mixed.
- [ ] "Show all" appears only when something is hidden and clears all hidden state.
- [ ] Clicking a legend entry in either chart toggles that subject in both.
- [ ] Hover tooltip lists subjects highest-value-first, matching their vertical order.
- [ ] Hovering a legend entry OR a sidebar row thickens that subject's line in both charts; leaving restores it.
- [ ] Existing controls still work: X axis, Color by, tick step, per-chart customize (size/Y range/X zoom/X-field), Y-metric selector. Sibling "Session metrics over time" chart is unchanged.
## Self-Review Notes
- **Spec coverage:** sidebar + groups + per-subject/per-group checkboxes (Task 2, Task 3 wiring); global visibility via `hiddenSubjects` (Task 1 `toggleInSet`/`setIdsHidden`, Task 3); legend click toggle (Task 3 Step 6); value-ordered tooltip (Task 1 `sortPayloadByValueDesc`, Task 3 Step 3); legend + sidebar hover highlight via `highlightedSubject` + `strokeWidth` (Task 2 onHighlight, Task 3 Steps 2/6). All four behaviors covered.
- **Naming consistency:** `toggleHidden`, `toggleGroup`, `showAll`, `setHighlightedSubject`, `hiddenSubjects`, `highlightedSubject`, `sidebarOpen` used identically across Task 2 props and Task 3 wiring.
- **No DB/API:** all state is `useState` in the component; no imports from `api/`.
@@ -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 `<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 34); `PUT /:id/plot-config` with validator, no audit log (Task 1 Steps 67, 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 46); `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.
@@ -0,0 +1,705 @@
# Experiment Data Export (CSV) 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:** Add a client-side CSV export on the experiment page that produces a date×subject matrix for any chosen daily parameter (including session metrics).
**Architecture:** A pure, unit-tested helper module (`frontend/src/lib/dataExport.js`) enumerates exportable parameters, pivots fetched daily statuses into a date-row × subject-column matrix, and serializes to CSV. A modal component (`ExportDataModal.jsx`) fetches the full daily-statuses set on open, drives the picker, and triggers a browser download. `ExperimentDetail.jsx` gets an "Export data" button. No backend changes — the existing `GET /:id/daily-statuses` endpoint already returns every field needed.
**Tech Stack:** React 18, Jest + @testing-library, existing `experimentsApi` axios client, existing `Modal`/`Button` UI components.
---
## Reference: data shapes (read before starting)
**Daily template field** (`dailyTemplate` array items):
```js
{ fieldId: '0000…0002', key: 'vitals', label: 'Vitals', type: 'text', builtin: true, active: true, tags: [] }
```
- `builtin: true` → value stored at `status[field.key]` (e.g. `status.vitals`).
- `builtin: false` → value stored at `status.custom_fields[field.fieldId]`.
**Daily status** (from `experimentsApi.getDailyStatuses(id, {})`):
```js
{
id, animal_id, date: '2026-07-01T00:00:00.000Z',
experiment_description, vitals, treatment, notes, // builtin columns
custom_fields: { '<fieldId>': value, } | null,
analysis_summary: { total: 12, success_rate: 0.83, counts: { Success: 10, Failure: 2 } } | null,
}
```
**Animal**: `{ id, animal_name, animal_id_string, subject_info, … }`.
**Parameter descriptor** (produced by `listExportableParams`, consumed everywhere):
```js
{ id, label, group: 'metrics' | 'daily', kind: 'total'|'success_rate'|'category'|'builtin'|'custom',
category?, statusKey?, fieldId? }
```
---
## Task 1: `extractValue` — read one parameter's value from a status
**Files:**
- Create: `frontend/src/lib/dataExport.js`
- Test: `frontend/tests/dataExport.test.js`
- [ ] **Step 1: Write the failing test**
Create `frontend/tests/dataExport.test.js`:
```js
import { extractValue } from '../src/lib/dataExport';
const status = {
vitals: 'ok',
custom_fields: { 'f-weight': 250 },
analysis_summary: { total: 12, success_rate: 0.83, counts: { Success: 10, Failure: 2 } },
};
describe('extractValue', () => {
it('reads a builtin field via statusKey', () => {
expect(extractValue(status, { kind: 'builtin', statusKey: 'vitals' })).toBe('ok');
});
it('reads a custom field via fieldId', () => {
expect(extractValue(status, { kind: 'custom', fieldId: 'f-weight' })).toBe(250);
});
it('reads session-metric total', () => {
expect(extractValue(status, { kind: 'total' })).toBe(12);
});
it('reads session-metric success_rate as a raw 0-1 fraction', () => {
expect(extractValue(status, { kind: 'success_rate' })).toBe(0.83);
});
it('reads a dynamic count category', () => {
expect(extractValue(status, { kind: 'category', category: 'Failure' })).toBe(2);
});
it('returns null when analysis_summary is absent', () => {
expect(extractValue({ custom_fields: {} }, { kind: 'total' })).toBeNull();
expect(extractValue({}, { kind: 'category', category: 'Success' })).toBeNull();
});
it('returns null for a missing custom field', () => {
expect(extractValue({ custom_fields: {} }, { kind: 'custom', fieldId: 'nope' })).toBeNull();
expect(extractValue({}, { kind: 'builtin', statusKey: 'notes' })).toBeNull();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd frontend && npx jest tests/dataExport.test.js -t extractValue`
Expected: FAIL — "extractValue is not a function" / cannot import.
- [ ] **Step 3: Write minimal implementation**
Create `frontend/src/lib/dataExport.js`:
```js
// Pure helpers for exporting daily-parameter data as a date×subject CSV matrix.
// No React, no I/O — mirrors the crossSubjectChart.js pure-helper pattern.
// Read a single parameter's raw value from one daily status.
// Returns null when the value is absent. Note: 0 and '' are returned as-is
// (real values); only genuinely-missing lookups become null.
export function extractValue(status, param) {
if (!status || !param) return null;
switch (param.kind) {
case 'total':
return status.analysis_summary?.total ?? null;
case 'success_rate':
return status.analysis_summary?.success_rate ?? null;
case 'category':
return status.analysis_summary?.counts?.[param.category] ?? null;
case 'builtin':
return status[param.statusKey] ?? null;
case 'custom':
return status.custom_fields?.[param.fieldId] ?? null;
default:
return null;
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd frontend && npx jest tests/dataExport.test.js -t extractValue`
Expected: PASS (7 assertions).
- [ ] **Step 5: Commit**
```bash
git add frontend/src/lib/dataExport.js frontend/tests/dataExport.test.js
git commit -m "feat(frontend): extractValue helper for data export"
```
---
## Task 2: `listExportableParams` — enumerate the picker options
**Files:**
- Modify: `frontend/src/lib/dataExport.js`
- Test: `frontend/tests/dataExport.test.js`
- [ ] **Step 1: Write the failing test**
Append to `frontend/tests/dataExport.test.js`:
```js
import { listExportableParams } from '../src/lib/dataExport';
const dailyTemplate = [
{ fieldId: 'b-vitals', key: 'vitals', label: 'Vitals', builtin: true, active: true },
{ fieldId: 'c-weight', key: 'weight', label: 'Weight (g)', builtin: false, active: false },
];
const statuses = [
{ analysis_summary: { counts: { Success: 1, Failure: 0 } } },
{ analysis_summary: { counts: { Success: 2, Other: 1 } } },
{ analysis_summary: null },
];
describe('listExportableParams', () => {
const params = listExportableParams(dailyTemplate, statuses);
it('starts with the two fixed session metrics', () => {
expect(params.slice(0, 2)).toEqual([
expect.objectContaining({ id: '__total__', label: 'Total attempts', group: 'metrics', kind: 'total' }),
expect.objectContaining({ id: '__success_rate__', label: 'Success rate', group: 'metrics', kind: 'success_rate' }),
]);
});
it('adds one metrics entry per dynamic count category, sorted', () => {
const cats = params.filter((p) => p.kind === 'category').map((p) => p.label);
expect(cats).toEqual(['Failure', 'Other', 'Success']);
});
it('includes every template field (incl. inactive) in the daily group', () => {
const daily = params.filter((p) => p.group === 'daily');
expect(daily).toEqual([
expect.objectContaining({ label: 'Vitals', kind: 'builtin', statusKey: 'vitals' }),
expect.objectContaining({ label: 'Weight (g)', kind: 'custom', fieldId: 'c-weight' }),
]);
});
it('gives every param a unique id', () => {
const ids = params.map((p) => p.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('tolerates empty/missing inputs', () => {
expect(listExportableParams(undefined, undefined).length).toBe(2); // just the two fixed metrics
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd frontend && npx jest tests/dataExport.test.js -t listExportableParams`
Expected: FAIL — "listExportableParams is not a function".
- [ ] **Step 3: Write minimal implementation**
Append to `frontend/src/lib/dataExport.js`:
```js
// Build the grouped, ordered list of exportable parameters.
// Session metrics (total, success_rate, then each dynamic count category found
// in the data) come first; then every field defined in the daily template,
// including inactive ones (historical data may exist).
export function listExportableParams(dailyTemplate, statuses) {
const params = [
{ id: '__total__', label: 'Total attempts', group: 'metrics', kind: 'total' },
{ id: '__success_rate__', label: 'Success rate', group: 'metrics', kind: 'success_rate' },
];
const cats = new Set();
for (const s of statuses ?? []) {
const counts = s?.analysis_summary?.counts;
if (counts && typeof counts === 'object') {
for (const k of Object.keys(counts)) cats.add(k);
}
}
for (const cat of [...cats].sort()) {
params.push({ id: `__cat__${cat}`, label: cat, group: 'metrics', kind: 'category', category: cat });
}
for (const f of dailyTemplate ?? []) {
if (f.builtin) {
params.push({ id: `b:${f.fieldId}`, label: f.label, group: 'daily', kind: 'builtin', statusKey: f.key });
} else {
params.push({ id: `c:${f.fieldId}`, label: f.label, group: 'daily', kind: 'custom', fieldId: f.fieldId });
}
}
return params;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd frontend && npx jest tests/dataExport.test.js -t listExportableParams`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/lib/dataExport.js frontend/tests/dataExport.test.js
git commit -m "feat(frontend): listExportableParams enumerates export options"
```
---
## Task 3: `buildMatrix` — pivot statuses into date rows × subject columns
**Files:**
- Modify: `frontend/src/lib/dataExport.js`
- Test: `frontend/tests/dataExport.test.js`
- [ ] **Step 1: Write the failing test**
Append to `frontend/tests/dataExport.test.js`:
```js
import { buildMatrix } from '../src/lib/dataExport';
const animals = [
{ id: 'a2', animal_name: 'Beta', animal_id_string: 'R-002' },
{ id: 'a1', animal_name: 'Alpha', animal_id_string: 'R-001' },
];
const totalParam = { kind: 'total' };
describe('buildMatrix', () => {
it('orders columns by animal_name and rows by date ascending', () => {
const statuses = [
{ animal_id: 'a1', date: '2026-07-02T00:00:00.000Z', analysis_summary: { total: 5 } },
{ animal_id: 'a2', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 9 } },
];
const m = buildMatrix(statuses, animals, totalParam);
expect(m.columns.map((c) => c.header)).toEqual(['Alpha', 'Beta']);
expect(m.rows.map((r) => r.date)).toEqual(['2026-07-01', '2026-07-02']);
// Row for 07-01: Alpha blank, Beta 9
expect(m.rows[0].values).toEqual(['', 9]);
// Row for 07-02: Alpha 5, Beta blank
expect(m.rows[1].values).toEqual([5, '']);
});
it('includes only dates that have at least one value (0 counts as a value)', () => {
const statuses = [
{ animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 0 } },
{ animal_id: 'a1', date: '2026-07-03T00:00:00.000Z', analysis_summary: null }, // no value
];
const m = buildMatrix(statuses, animals, totalParam);
expect(m.rows.map((r) => r.date)).toEqual(['2026-07-01']);
expect(m.rows[0].values).toEqual([0, '']);
});
it('disambiguates duplicate animal names with the id string', () => {
const dupAnimals = [
{ id: 'a1', animal_name: 'Rat', animal_id_string: 'R-001' },
{ id: 'a2', animal_name: 'Rat', animal_id_string: 'R-002' },
];
const statuses = [{ animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 1 } }];
const m = buildMatrix(statuses, dupAnimals, totalParam);
expect(m.columns.map((c) => c.header)).toEqual(['Rat (R-001)', 'Rat (R-002)']);
});
it('keeps the first status when a subject has duplicates on one date', () => {
const statuses = [
{ animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 7 } },
{ animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 99 } },
];
const m = buildMatrix(statuses, animals, totalParam);
expect(m.rows[0].values).toEqual([7, '']);
});
it('returns empty rows when no status has a value', () => {
const m = buildMatrix([{ animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: null }], animals, totalParam);
expect(m.rows).toEqual([]);
expect(m.columns.length).toBe(2);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd frontend && npx jest tests/dataExport.test.js -t buildMatrix`
Expected: FAIL — "buildMatrix is not a function".
- [ ] **Step 3: Write minimal implementation**
Append to `frontend/src/lib/dataExport.js`:
```js
// A cell has a value unless it is null, undefined, or the empty string.
// (0 and false are real values.)
function hasValue(v) {
return v !== null && v !== undefined && v !== '';
}
function dateKey(date) {
return String(date).slice(0, 10); // ISO datetime or date → YYYY-MM-DD
}
// Pivot statuses into { columns, rows }.
// columns: [{ id, header }] — all animals, ordered by name, headers
// disambiguated with animal_id_string on duplicate names.
// rows: [{ date, values }] — one per date that has ≥1 value; values are
// aligned to columns, blank cells are '' (empty string).
export function buildMatrix(statuses, animals, param) {
const sortedAnimals = [...(animals ?? [])].sort((a, b) =>
String(a.animal_name ?? '').localeCompare(String(b.animal_name ?? '')),
);
const nameCounts = new Map();
for (const a of sortedAnimals) {
const n = a.animal_name ?? '';
nameCounts.set(n, (nameCounts.get(n) ?? 0) + 1);
}
const columns = sortedAnimals.map((a) => {
const name = a.animal_name ?? '';
const header = nameCounts.get(name) > 1 ? `${name} (${a.animal_id_string ?? ''})` : name;
return { id: a.id, header };
});
const colIndex = new Map(columns.map((c, i) => [c.id, i]));
// cells: dateKey -> Map(animalId -> value); first status per (date, animal) wins.
const cells = new Map();
const datesWithData = new Set();
const ordered = [...(statuses ?? [])].sort((a, b) => dateKey(a.date).localeCompare(dateKey(b.date)));
for (const s of ordered) {
if (!colIndex.has(s.animal_id)) continue;
const dk = dateKey(s.date);
if (!cells.has(dk)) cells.set(dk, new Map());
const byAnimal = cells.get(dk);
if (byAnimal.has(s.animal_id)) continue; // first wins
const v = extractValue(s, param);
byAnimal.set(s.animal_id, v);
if (hasValue(v)) datesWithData.add(dk);
}
const rows = [...datesWithData].sort().map((dk) => {
const byAnimal = cells.get(dk);
const values = columns.map((c) => {
const v = byAnimal.has(c.id) ? byAnimal.get(c.id) : null;
return hasValue(v) ? v : '';
});
return { date: dk, values };
});
return { columns, rows };
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd frontend && npx jest tests/dataExport.test.js -t buildMatrix`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/lib/dataExport.js frontend/tests/dataExport.test.js
git commit -m "feat(frontend): buildMatrix pivots statuses into date×subject grid"
```
---
## Task 4: `toCSV` + `csvFilename` — serialize and name the file
**Files:**
- Modify: `frontend/src/lib/dataExport.js`
- Test: `frontend/tests/dataExport.test.js`
- [ ] **Step 1: Write the failing test**
Append to `frontend/tests/dataExport.test.js`:
```js
import { toCSV, csvFilename } from '../src/lib/dataExport';
describe('toCSV', () => {
it('writes a header row of Date + column headers, then data rows', () => {
const matrix = {
columns: [{ id: 'a1', header: 'Alpha' }, { id: 'a2', header: 'Beta' }],
rows: [{ date: '2026-07-01', values: [5, ''] }, { date: '2026-07-02', values: ['', 9] }],
};
expect(toCSV(matrix)).toBe('Date,Alpha,Beta\n2026-07-01,5,\n2026-07-02,,9');
});
it('escapes commas, quotes, and newlines per RFC 4180', () => {
const matrix = {
columns: [{ id: 'a1', header: 'Note, field' }],
rows: [{ date: '2026-07-01', values: ['he said "hi"'] }, { date: '2026-07-02', values: ['line1\nline2'] }],
};
expect(toCSV(matrix)).toBe(
'Date,"Note, field"\n2026-07-01,"he said ""hi"""\n2026-07-02,"line1\nline2"',
);
});
});
describe('csvFilename', () => {
it('slugifies the experiment title and parameter label', () => {
expect(csvFilename('My Study #1', 'Success rate')).toBe('My-Study-1-Success-rate.csv');
});
it('falls back to "export" when both slugs are empty', () => {
expect(csvFilename('', '')).toBe('export.csv');
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd frontend && npx jest tests/dataExport.test.js -t "toCSV|csvFilename"`
Expected: FAIL — "toCSV is not a function".
- [ ] **Step 3: Write minimal implementation**
Append to `frontend/src/lib/dataExport.js`:
```js
function escapeCsvCell(value) {
const s = value === null || value === undefined ? '' : String(value);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
// Serialize a matrix to an RFC-4180-ish CSV string (LF line endings).
// First column is the date; remaining columns follow matrix.columns order.
export function toCSV(matrix) {
const header = ['Date', ...matrix.columns.map((c) => c.header)].map(escapeCsvCell).join(',');
const lines = matrix.rows.map((r) => [r.date, ...r.values].map(escapeCsvCell).join(','));
return [header, ...lines].join('\n');
}
// Build a download filename from the experiment title and parameter label.
export function csvFilename(title, label) {
const slug = (s) => String(s ?? '').replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '');
const base = [slug(title), slug(label)].filter(Boolean).join('-');
return `${base || 'export'}.csv`;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd frontend && npx jest tests/dataExport.test.js`
Expected: PASS (all describe blocks in the file).
- [ ] **Step 5: Commit**
```bash
git add frontend/src/lib/dataExport.js frontend/tests/dataExport.test.js
git commit -m "feat(frontend): toCSV + csvFilename for data export"
```
---
## Task 5: `ExportDataModal` component
**Files:**
- Create: `frontend/src/components/ExportDataModal.jsx`
This component is rendered as the *content* of the shared `Modal` (same pattern as `AnimalForm`/`TemplateEditor`), so it mounts only when the modal opens — the fetch runs on mount.
- [ ] **Step 1: Create the component**
Create `frontend/src/components/ExportDataModal.jsx`:
```jsx
import React, { useEffect, useMemo, useState } from 'react';
import { experimentsApi } from '../api/client';
import Button from './ui/Button';
import Alert from './ui/Alert';
import { listExportableParams, buildMatrix, toCSV, csvFilename } from '../lib/dataExport';
const GROUP_LABELS = { metrics: 'Session metrics', daily: 'Daily record fields' };
function triggerDownload(filename, text) {
const blob = new Blob([text], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export default function ExportDataModal({ experimentTitle, experimentId, dailyTemplate, animals, onClose }) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [statuses, setStatuses] = useState([]);
const [selectedId, setSelectedId] = useState(null);
useEffect(() => {
let alive = true;
setLoading(true);
setError(null);
experimentsApi.getDailyStatuses(experimentId, {})
.then((data) => { if (alive) setStatuses(data); })
.catch((err) => { if (alive) setError(err.message); })
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [experimentId]);
const params = useMemo(() => listExportableParams(dailyTemplate, statuses), [dailyTemplate, statuses]);
// Default the selection to the first parameter once params are available.
useEffect(() => {
if (selectedId === null && params.length > 0) setSelectedId(params[0].id);
}, [params, selectedId]);
const selectedParam = params.find((p) => p.id === selectedId) ?? null;
const matrix = useMemo(
() => (selectedParam ? buildMatrix(statuses, animals, selectedParam) : { columns: [], rows: [] }),
[statuses, animals, selectedParam],
);
const hasData = matrix.rows.length > 0;
// Group params for <optgroup> rendering, preserving order.
const grouped = useMemo(() => {
const out = [];
for (const p of params) {
let g = out.find((x) => x.group === p.group);
if (!g) { g = { group: p.group, items: [] }; out.push(g); }
g.items.push(p);
}
return out;
}, [params]);
function handleExport() {
if (!hasData || !selectedParam) return;
triggerDownload(csvFilename(experimentTitle, selectedParam.label), toCSV(matrix));
onClose();
}
if (loading) return <p className="text-sm text-gray-400" aria-live="polite">Loading data</p>;
if (error) return <Alert type="error" message={error} />;
return (
<div className="space-y-4">
<p className="text-sm text-gray-500">
Export one parameter as a CSV file each row is a date, each column is a subject.
</p>
<div>
<label htmlFor="export-param" className="block text-xs font-medium text-gray-500 mb-1">Parameter</label>
<select
id="export-param"
value={selectedId ?? ''}
onChange={(e) => setSelectedId(e.target.value)}
className="w-full border border-gray-200 rounded px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400"
>
{grouped.map((g) => (
<optgroup key={g.group} label={GROUP_LABELS[g.group] ?? g.group}>
{g.items.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
</optgroup>
))}
</select>
</div>
<p className="text-xs text-gray-400">
{hasData
? `${matrix.rows.length} date${matrix.rows.length !== 1 ? 's' : ''} × ${matrix.columns.length} subject${matrix.columns.length !== 1 ? 's' : ''}`
: 'No data for this parameter yet.'}
</p>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button onClick={handleExport} disabled={!hasData}>Export CSV</Button>
</div>
</div>
);
}
```
- [ ] **Step 2: Verify it compiles (build)**
Run: `cd frontend && npx vite build`
Expected: build succeeds with no import/JSX errors. (There is no `ExportDataModal` test — its logic lives in the unit-tested `dataExport.js`; the DOM download path is verified manually in Task 7.)
- [ ] **Step 3: Commit**
```bash
git add frontend/src/components/ExportDataModal.jsx
git commit -m "feat(frontend): ExportDataModal — pick a parameter and download CSV"
```
---
## Task 6: Wire the "Export data" button into the experiment page
**Files:**
- Modify: `frontend/src/pages/ExperimentDetail.jsx`
- [ ] **Step 1: Import the modal**
In `frontend/src/pages/ExperimentDetail.jsx`, add to the imports near the other component imports (after the `ExperimentCalendar` import on line 11):
```jsx
import ExportDataModal from '../components/ExportDataModal';
```
- [ ] **Step 2: Add modal open/close state**
Immediately after the `const [showSubjectTemplate, setShowSubjectTemplate] = useState(false);` line (around line 64), add:
```jsx
const [showExport, setShowExport] = useState(false);
```
- [ ] **Step 3: Add the header button**
In the header actions area, replace the single Add-Animal button block:
```jsx
<Button onClick={() => setShowAddAnimal(true)}>+ Add Animal</Button>
```
with:
```jsx
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setShowExport(true)}>Export data</Button>
<Button onClick={() => setShowAddAnimal(true)}>+ Add Animal</Button>
</div>
```
- [ ] **Step 4: Render the modal**
Just before the closing `</div>` and the final Edit-Animal `Modal` (after the `Edit Animal` modal block, around line 406), add:
```jsx
<Modal isOpen={showExport} onClose={() => setShowExport(false)} title="Export data" size="md">
<ExportDataModal
experimentId={id}
experimentTitle={experiment.title}
dailyTemplate={dailyTemplate}
animals={animals}
onClose={() => setShowExport(false)}
/>
</Modal>
```
- [ ] **Step 5: Build to verify wiring**
Run: `cd frontend && npx vite build`
Expected: build succeeds.
- [ ] **Step 6: Commit**
```bash
git add frontend/src/pages/ExperimentDetail.jsx
git commit -m "feat(frontend): Export data button + modal on experiment page"
```
---
## Task 7: Full-suite verification & manual smoke test
**Files:** none (verification only)
- [ ] **Step 1: Run the whole frontend unit suite**
Run: `cd frontend && npm test`
Expected: all suites PASS, including `tests/dataExport.test.js`.
- [ ] **Step 2: Manual smoke test**
Bring up the dev stack (`docker-compose -f docker-compose.dev.yml up` or the project's usual dev command), open an experiment that has animals and some daily statuses, then:
- Click **Export data** → modal opens, parameter dropdown shows a **Session metrics** group (Total attempts, Success rate, any count categories) and a **Daily record fields** group (all template fields).
- The summary line shows a plausible "N dates × M subjects".
- Pick a session metric and a daily field; confirm the summary updates and **Export CSV** downloads a file.
- Open the CSV: first column `Date` (YYYY-MM-DD rows ascending), one column per subject (by name), correct values, blank cells where a subject had no value, and **success rate as a raw 01 fraction**.
- Pick a parameter with no data → summary reads "No data for this parameter yet." and **Export CSV** is disabled.
- [ ] **Step 3: Final commit (if any manual fixes were needed)**
```bash
git add -A
git commit -m "chore(frontend): finalize experiment data export"
```
---
## Self-review notes (for the implementer)
- The whole feature is client-side; **no backend files change**. If a test asks you to touch `backend/`, stop — the plan is wrong.
- `success_rate` is exported **raw (01)**, deliberately not multiplied to a percentage.
- All daily template fields are exportable, **including inactive ones** — this is intentional (historical data).
- Parameter descriptor property names (`kind`, `statusKey`, `fieldId`, `category`, `group`, `id`, `label`) must stay identical across `listExportableParams`, `extractValue`, `buildMatrix`, and `ExportDataModal`.
@@ -0,0 +1,926 @@
# Configurable Data Export 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:** Let the export modal assign any of Date/Subject/Parameter to rows and columns, pin the third dimension (shown as a top context line), and emit each subject's group in a separate cell when Subject is an axis.
**Architecture:** Generalize the pure helpers in `frontend/src/lib/dataExport.js` around three interchangeable dimensions (date, subject, parameter). Each dimension produces an ordered list of "members"; `buildMatrix` resolves every cell's `(dateKey, animalId, param)` triple through a status index. `ExportDataModal.jsx` becomes glue over these helpers with four controls. `ExperimentDetail.jsx` passes two new props.
**Tech Stack:** React 18, plain ES modules, Jest + `@testing-library/react` (jsdom). No new dependencies.
**Spec:** `docs/superpowers/specs/2026-07-19-configurable-data-export-design.md`
## Global Constraints
- `dataExport.js` stays pure: no React, no I/O (mirrors `crossSubjectChart.js`).
- Cell value semantics: `0` and `''` are **real** values; only genuinely-missing lookups render as blank (`''`). Use the existing `hasValue` helper.
- CSV: LF (`\n`) line endings; escape every cell via the existing `escapeCsvCell`.
- Empty-member rule: **Subject members are always kept**; **Date and Parameter members are dropped when entirely blank** in the grid.
- Group field ids: `__none__` (None), `__name__` (Name), `__id__` (Subject ID), otherwise a `subject_info` field id. Missing group value renders as `—`.
- Default modal state must reproduce today's export exactly: Rows = Date, Columns = Subject, Fixed = Parameter (first parameter).
- Context line is **beginning only** (no footer); two cells: pinned dimension label, pinned value.
- Run tests from `frontend/`. Single file: `npx jest tests/dataExport.test.js`. Component file: `npx jest tests/ExportDataModal.test.jsx`.
---
### Task 1: Dimension primitives + date members + status index
**Files:**
- Modify: `frontend/src/lib/dataExport.js` (add exports; keep existing `extractValue`, `listExportableParams`, `hasValue`, `dateKey`, `escapeCsvCell`, `csvFilename`)
- Test: `frontend/tests/dataExport.test.js`
**Interfaces:**
- Consumes: existing `dateKey(date)` (private), `hasValue(v)` (private) in `dataExport.js`.
- Produces:
- `EXPORT_DIMENSIONS: Array<{ dim: 'date'|'subject'|'parameter', label: string }>`
- `dimLabel(dim: string): string`
- `otherDimension(rowDim: string, colDim: string): string` — the leftover dimension
- `subjectGroupValue(animal: object, groupField: string): string|null``null` when `groupField` is falsy/`__none__`; `—` when the value is missing/empty
- `listDateMembers(statuses: array): Array<{ id, label, dateKey }>` — sorted unique dateKeys
- `buildStatusIndex(statuses: array): Map<string, Map<any, status>>``dateKey → animalId → status`, first-wins
- [ ] **Step 1: Write the failing tests**
Append to `frontend/tests/dataExport.test.js`:
```js
import {
EXPORT_DIMENSIONS, dimLabel, otherDimension, subjectGroupValue,
listDateMembers, buildStatusIndex,
} from '../src/lib/dataExport';
describe('dimension primitives', () => {
it('exposes the three dimensions with labels', () => {
expect(EXPORT_DIMENSIONS.map((d) => d.dim)).toEqual(['date', 'subject', 'parameter']);
expect(dimLabel('date')).toBe('Date');
expect(dimLabel('subject')).toBe('Subject');
expect(dimLabel('parameter')).toBe('Parameter');
});
it('otherDimension returns the leftover', () => {
expect(otherDimension('date', 'subject')).toBe('parameter');
expect(otherDimension('parameter', 'date')).toBe('subject');
});
});
describe('subjectGroupValue', () => {
const animal = { animal_name: 'Alpha', animal_id_string: 'R-001', subject_info: { sex: 'M', cohort: '' } };
it('returns null when no group field', () => {
expect(subjectGroupValue(animal, '__none__')).toBeNull();
expect(subjectGroupValue(animal, '')).toBeNull();
});
it('resolves name / id / subject_info fields', () => {
expect(subjectGroupValue(animal, '__name__')).toBe('Alpha');
expect(subjectGroupValue(animal, '__id__')).toBe('R-001');
expect(subjectGroupValue(animal, 'sex')).toBe('M');
});
it('renders missing or empty values as an em dash', () => {
expect(subjectGroupValue(animal, 'cohort')).toBe('—');
expect(subjectGroupValue({}, 'sex')).toBe('—');
});
});
describe('listDateMembers', () => {
it('returns sorted unique dateKeys with id/label', () => {
const statuses = [
{ date: '2026-07-02T00:00:00.000Z' },
{ date: '2026-07-01T12:00:00.000Z' },
{ date: '2026-07-02T09:00:00.000Z' },
];
expect(listDateMembers(statuses)).toEqual([
{ id: '2026-07-01', label: '2026-07-01', dateKey: '2026-07-01' },
{ id: '2026-07-02', label: '2026-07-02', dateKey: '2026-07-02' },
]);
});
it('tolerates empty input', () => {
expect(listDateMembers(undefined)).toEqual([]);
});
});
describe('buildStatusIndex', () => {
it('indexes by dateKey then animalId, first status wins', () => {
const statuses = [
{ animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 7 } },
{ animal_id: 'a1', date: '2026-07-01T06:00:00.000Z', analysis_summary: { total: 99 } },
{ animal_id: 'a2', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 3 } },
];
const idx = buildStatusIndex(statuses);
expect(idx.get('2026-07-01').get('a1').analysis_summary.total).toBe(7);
expect(idx.get('2026-07-01').get('a2').analysis_summary.total).toBe(3);
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx jest tests/dataExport.test.js -t "dimension primitives"`
Expected: FAIL — `EXPORT_DIMENSIONS is not defined` / imports undefined.
- [ ] **Step 3: Write minimal implementation**
Add to `frontend/src/lib/dataExport.js` (after the existing `listExportableParams`, before `hasValue`):
```js
export const EXPORT_DIMENSIONS = [
{ dim: 'date', label: 'Date' },
{ dim: 'subject', label: 'Subject' },
{ dim: 'parameter', label: 'Parameter' },
];
export function dimLabel(dim) {
return EXPORT_DIMENSIONS.find((d) => d.dim === dim)?.label ?? '';
}
export function otherDimension(rowDim, colDim) {
return EXPORT_DIMENSIONS.map((d) => d.dim).find((d) => d !== rowDim && d !== colDim) ?? null;
}
// Resolve a subject's group value for a given group field id.
// null -> no grouping (field is falsy or '__none__')
// '—' -> grouping is on but this subject has no value
export function subjectGroupValue(animal, groupField) {
if (!groupField || groupField === '__none__') return null;
if (groupField === '__name__') return animal?.animal_name ?? '—';
if (groupField === '__id__') return animal?.animal_id_string ?? '—';
const v = animal?.subject_info?.[groupField];
return v === null || v === undefined || v === '' ? '—' : v;
}
export function listDateMembers(statuses) {
const keys = new Set();
for (const s of statuses ?? []) {
if (s?.date) keys.add(dateKey(s.date));
}
return [...keys].sort().map((k) => ({ id: k, label: k, dateKey: k }));
}
// dateKey -> Map(animalId -> status); first status per (date, animal) wins,
// scanning in date order (matches the legacy buildMatrix tie-break).
export function buildStatusIndex(statuses) {
const index = new Map();
const ordered = [...(statuses ?? [])].sort((a, b) => dateKey(a?.date).localeCompare(dateKey(b?.date)));
for (const s of ordered) {
if (!s?.date || s.animal_id === null || s.animal_id === undefined) continue;
const dk = dateKey(s.date);
if (!index.has(dk)) index.set(dk, new Map());
const byAnimal = index.get(dk);
if (!byAnimal.has(s.animal_id)) byAnimal.set(s.animal_id, s);
}
return index;
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npx jest tests/dataExport.test.js`
Expected: PASS (new suites green, all pre-existing suites still green).
- [ ] **Step 5: Commit**
```bash
git add frontend/src/lib/dataExport.js frontend/tests/dataExport.test.js
git commit -m "feat(export): dimension primitives, date members, status index"
```
---
### Task 2: Subject + parameter members, dimension dispatcher
**Files:**
- Modify: `frontend/src/lib/dataExport.js`
- Test: `frontend/tests/dataExport.test.js`
**Interfaces:**
- Consumes: `subjectGroupValue`, `listDateMembers`, `listExportableParams` (existing).
- Produces:
- `listSubjectMembers(animals, groupField): Array<{ id, label, animalId, group }>` — headers disambiguated by `animal_id_string` on duplicate names; clustered by group value then sorted by name/header when grouping is on, else name/header only.
- `listParameterMembers(dailyTemplate, statuses): Array<{ id, label, param, group }>` — wraps `listExportableParams`.
- `listDimensionMembers(dim, { statuses, animals, dailyTemplate, groupField }): Array<member>`
- [ ] **Step 1: Write the failing tests**
Append to `frontend/tests/dataExport.test.js`:
```js
import { listSubjectMembers, listParameterMembers, listDimensionMembers } from '../src/lib/dataExport';
describe('listSubjectMembers', () => {
const animals = [
{ id: 'a2', animal_name: 'Beta', animal_id_string: 'R-002', subject_info: { grp: 'Drug' } },
{ id: 'a1', animal_name: 'Alpha', animal_id_string: 'R-001', subject_info: { grp: 'Control' } },
{ id: 'a3', animal_name: 'Gamma', animal_id_string: 'R-003', subject_info: { grp: 'Control' } },
];
it('orders by name and carries group=null when no group field', () => {
const m = listSubjectMembers(animals, '__none__');
expect(m.map((s) => s.label)).toEqual(['Alpha', 'Beta', 'Gamma']);
expect(m.every((s) => s.group === null)).toBe(true);
expect(m[0]).toMatchObject({ id: 'a1', animalId: 'a1' });
});
it('clusters by group value then name when grouping is on', () => {
const m = listSubjectMembers(animals, 'grp');
expect(m.map((s) => [s.group, s.label])).toEqual([
['Control', 'Alpha'], ['Control', 'Gamma'], ['Drug', 'Beta'],
]);
});
it('disambiguates duplicate names with the id string', () => {
const dup = [
{ id: 'a1', animal_name: 'Rat', animal_id_string: 'R-001' },
{ id: 'a2', animal_name: 'Rat', animal_id_string: 'R-002' },
];
expect(listSubjectMembers(dup, '__none__').map((s) => s.label)).toEqual(['Rat (R-001)', 'Rat (R-002)']);
});
});
describe('listParameterMembers / listDimensionMembers', () => {
const dailyTemplate = [{ fieldId: 'b-vitals', key: 'vitals', label: 'Vitals', builtin: true, active: true }];
const statuses = [{ date: '2026-07-01T00:00:00.000Z', animal_id: 'a1', analysis_summary: { total: 1 } }];
const animals = [{ id: 'a1', animal_name: 'Alpha', animal_id_string: 'R-001' }];
it('wraps params with id/label/param/group', () => {
const m = listParameterMembers(dailyTemplate, statuses);
expect(m[0]).toMatchObject({ id: '__total__', label: 'Total attempts', group: 'metrics' });
expect(m[0].param).toMatchObject({ kind: 'total' });
});
it('dispatches by dimension', () => {
const ctx = { statuses, animals, dailyTemplate, groupField: '__none__' };
expect(listDimensionMembers('date', ctx).map((d) => d.id)).toEqual(['2026-07-01']);
expect(listDimensionMembers('subject', ctx).map((d) => d.id)).toEqual(['a1']);
expect(listDimensionMembers('parameter', ctx)[0].id).toBe('__total__');
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx jest tests/dataExport.test.js -t "listSubjectMembers"`
Expected: FAIL — `listSubjectMembers is not defined`.
- [ ] **Step 3: Write minimal implementation**
Add to `frontend/src/lib/dataExport.js` (after `buildStatusIndex`):
```js
export function listSubjectMembers(animals, groupField) {
const list = [...(animals ?? [])];
const nameCounts = new Map();
for (const a of list) {
const n = a?.animal_name ?? '';
nameCounts.set(n, (nameCounts.get(n) ?? 0) + 1);
}
const members = list.map((a) => {
const name = a?.animal_name ?? '';
const label = nameCounts.get(name) > 1 ? `${name} (${a?.animal_id_string ?? ''})` : name;
return { id: a.id, label, animalId: a.id, group: subjectGroupValue(a, groupField) };
});
const grouped = !!groupField && groupField !== '__none__';
members.sort((x, y) => {
if (grouped) {
const g = String(x.group ?? '').localeCompare(String(y.group ?? ''));
if (g !== 0) return g;
}
return String(x.label).localeCompare(String(y.label));
});
return members;
}
export function listParameterMembers(dailyTemplate, statuses) {
return listExportableParams(dailyTemplate, statuses).map((p) => ({
id: p.id, label: p.label, param: p, group: p.group,
}));
}
export function listDimensionMembers(dim, { statuses, animals, dailyTemplate, groupField }) {
if (dim === 'date') return listDateMembers(statuses);
if (dim === 'subject') return listSubjectMembers(animals, groupField);
if (dim === 'parameter') return listParameterMembers(dailyTemplate, statuses);
return [];
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npx jest tests/dataExport.test.js`
Expected: PASS (all suites green).
- [ ] **Step 5: Commit**
```bash
git add frontend/src/lib/dataExport.js frontend/tests/dataExport.test.js
git commit -m "feat(export): subject/parameter members + dimension dispatcher"
```
---
### Task 3: Generalized buildMatrix
**Files:**
- Modify: `frontend/src/lib/dataExport.js` (replace the existing `buildMatrix`)
- Test: `frontend/tests/dataExport.test.js` (replace the existing `describe('buildMatrix', ...)` block)
**Interfaces:**
- Consumes: `buildStatusIndex`, `listDimensionMembers`, `otherDimension`, `dimLabel`, `extractValue`, `hasValue`.
- Produces:
`buildMatrix(config, ctx)` where
- `config = { rowDim, colDim, pinnedMember, groupField }`
- `ctx = { statuses, animals, dailyTemplate }`
- returns `{ rowDim, colDim, pinnedDim, corner: string, context: { label, value }, groupAxis: 'row'|'col'|null, columns: Array<{ id, label, group }>, rows: Array<{ member: { id, label, group }, values: Array<value|''> }> }`
- Empty-member rule: subject members always kept; date/parameter rows and columns dropped when entirely blank.
- `groupAxis`: `'row'` when `rowDim === 'subject'` and grouping on; `'col'` when `colDim === 'subject'` and grouping on; else `null`.
- [ ] **Step 1: Replace the failing tests**
In `frontend/tests/dataExport.test.js`, **delete the entire existing `describe('buildMatrix', ...)` block** (the one using the old `buildMatrix(statuses, animals, totalParam)` signature) and the `const totalParam = ...` line, then add:
```js
const gAnimals = [
{ id: 'a2', animal_name: 'Beta', animal_id_string: 'R-002', subject_info: { grp: 'Drug' } },
{ id: 'a1', animal_name: 'Alpha', animal_id_string: 'R-001', subject_info: { grp: 'Control' } },
];
const gStatuses = [
{ animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 5, success_rate: 0.5 } },
{ animal_id: 'a2', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 9, success_rate: 0.9 } },
{ animal_id: 'a1', date: '2026-07-02T00:00:00.000Z', analysis_summary: { total: 7, success_rate: 0.7 } },
];
const ctx = { statuses: gStatuses, animals: gAnimals, dailyTemplate: [] };
const totalMember = { id: '__total__', label: 'Total attempts', param: { kind: 'total' }, group: 'metrics' };
describe('buildMatrix', () => {
it('default preset: rows=date, cols=subject, pinned=parameter (legacy layout)', () => {
const m = buildMatrix({ rowDim: 'date', colDim: 'subject', pinnedMember: totalMember, groupField: '__none__' }, ctx);
expect(m.corner).toBe('Date');
expect(m.context).toEqual({ label: 'Parameter', value: 'Total attempts' });
expect(m.columns.map((c) => c.label)).toEqual(['Alpha', 'Beta']);
expect(m.rows.map((r) => r.member.label)).toEqual(['2026-07-01', '2026-07-02']);
expect(m.rows[0].values).toEqual([5, 9]);
expect(m.rows[1].values).toEqual([7, '']); // Beta blank on 07-02
expect(m.groupAxis).toBeNull();
});
it('drops blank date rows but keeps all subject columns', () => {
const statuses = [
{ animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 0 } }, // 0 is a value
{ animal_id: 'a1', date: '2026-07-03T00:00:00.000Z', analysis_summary: null }, // no value
];
const m = buildMatrix({ rowDim: 'date', colDim: 'subject', pinnedMember: totalMember, groupField: '__none__' },
{ statuses, animals: gAnimals, dailyTemplate: [] });
expect(m.rows.map((r) => r.member.label)).toEqual(['2026-07-01']);
expect(m.columns.length).toBe(2);
expect(m.rows[0].values).toEqual([0, '']);
});
it('subject as columns sets groupAxis=col and clusters subjects', () => {
const m = buildMatrix({ rowDim: 'date', colDim: 'subject', pinnedMember: totalMember, groupField: 'grp' }, ctx);
expect(m.groupAxis).toBe('col');
expect(m.columns.map((c) => [c.group, c.label])).toEqual([['Control', 'Alpha'], ['Drug', 'Beta']]);
});
it('subject as rows sets groupAxis=row; pinned date; cols=parameter', () => {
const dateMember = { id: '2026-07-01', label: '2026-07-01', dateKey: '2026-07-01' };
const m = buildMatrix({ rowDim: 'subject', colDim: 'parameter', pinnedMember: dateMember, groupField: 'grp' },
{ statuses: gStatuses, animals: gAnimals, dailyTemplate: [] });
expect(m.corner).toBe('Subject');
expect(m.context).toEqual({ label: 'Date', value: '2026-07-01' });
expect(m.groupAxis).toBe('row');
expect(m.rows.map((r) => [r.member.group, r.member.label])).toEqual([['Control', 'Alpha'], ['Drug', 'Beta']]);
// columns are parameters (Total attempts, Success rate); Alpha on 07-01: total 5, rate 0.5
const totalCol = m.columns.findIndex((c) => c.label === 'Total attempts');
expect(m.rows[0].values[totalCol]).toBe(5);
});
it('drops blank parameter columns (params always dropped when empty)', () => {
// dailyTemplate adds a custom field with no data anywhere -> its column drops
const dailyTemplate = [{ fieldId: 'c-x', key: 'x', label: 'X', builtin: false, active: true }];
const dateMember = { id: '2026-07-01', label: '2026-07-01', dateKey: '2026-07-01' };
const m = buildMatrix({ rowDim: 'subject', colDim: 'parameter', pinnedMember: dateMember, groupField: '__none__' },
{ statuses: gStatuses, animals: gAnimals, dailyTemplate });
expect(m.columns.map((c) => c.label)).not.toContain('X');
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx jest tests/dataExport.test.js -t "buildMatrix"`
Expected: FAIL — new call shape not yet implemented (old `buildMatrix` returns `columns[].header`, no `context`).
- [ ] **Step 3: Replace the implementation**
In `frontend/src/lib/dataExport.js`, **delete the old `buildMatrix` function** (the `export function buildMatrix(statuses, animals, param) { ... }` block and its doc comment) and replace with:
```js
// Pivot statuses into a 2-D matrix per the chosen row/column dimensions.
// The leftover (pinned) dimension is fixed to pinnedMember. Every cell resolves
// a (dateKey, animalId, param) triple through the status index.
export function buildMatrix(config, ctx) {
const { rowDim, colDim, pinnedMember, groupField } = config;
const pinnedDim = otherDimension(rowDim, colDim);
const index = buildStatusIndex(ctx.statuses);
const full = { ...ctx, groupField };
const rowMembers = listDimensionMembers(rowDim, full);
const colMembers = listDimensionMembers(colDim, full);
const cellValue = (rowM, colM) => {
const byDim = { [rowDim]: rowM, [colDim]: colM, [pinnedDim]: pinnedMember };
const dk = byDim.date?.dateKey ?? null;
const animalId = byDim.subject?.animalId ?? null;
const param = byDim.parameter?.param ?? null;
if (dk === null || animalId === null || animalId === undefined || !param) return '';
const status = index.get(dk)?.get(animalId);
if (!status) return '';
const v = extractValue(status, param);
return hasValue(v) ? v : '';
};
let rows = rowMembers.map((rowM) => ({
member: rowM,
values: colMembers.map((colM) => cellValue(rowM, colM)),
}));
// Empty-member rule: keep all subjects; drop entirely-blank date/parameter rows & columns.
if (rowDim !== 'subject') rows = rows.filter((r) => r.values.some((v) => v !== ''));
let keptCols = colMembers.map((_, i) => i);
if (colDim !== 'subject') keptCols = keptCols.filter((i) => rows.some((r) => r.values[i] !== ''));
const columns = keptCols.map((i) => colMembers[i]);
rows = rows.map((r) => ({ member: r.member, values: keptCols.map((i) => r.values[i]) }));
const grouped = !!groupField && groupField !== '__none__';
const groupAxis = grouped
? (rowDim === 'subject' ? 'row' : colDim === 'subject' ? 'col' : null)
: null;
return {
rowDim,
colDim,
pinnedDim,
corner: dimLabel(rowDim),
context: { label: dimLabel(pinnedDim), value: pinnedMember?.label ?? '' },
groupAxis,
columns,
rows,
};
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npx jest tests/dataExport.test.js`
Expected: PASS. (The `toCSV` suite still passes — it builds its own matrix literals and does not call `buildMatrix`; it is rewritten in Task 4.)
- [ ] **Step 5: Commit**
```bash
git add frontend/src/lib/dataExport.js frontend/tests/dataExport.test.js
git commit -m "feat(export): generalized buildMatrix over assignable dimensions"
```
---
### Task 4: toCSV with context line + group cell
**Files:**
- Modify: `frontend/src/lib/dataExport.js` (replace `toCSV`; leave `csvFilename` unchanged)
- Test: `frontend/tests/dataExport.test.js` (replace the existing `describe('toCSV', ...)` block)
**Interfaces:**
- Consumes: `escapeCsvCell` (existing), matrix shape from Task 3.
- Produces: `toCSV(matrix): string` — context line, blank line, optional group row (groupAxis==='col'), header row, data rows. Header/data gain a leading `Group` column when groupAxis==='row'. `csvFilename(title, label)` unchanged.
- [ ] **Step 1: Replace the failing tests**
In `frontend/tests/dataExport.test.js`, **delete the existing `describe('toCSV', ...)` block** and add:
```js
describe('toCSV', () => {
it('emits context line, blank line, header, data (no group)', () => {
const matrix = {
corner: 'Date',
context: { label: 'Parameter', value: 'Total attempts' },
groupAxis: null,
columns: [{ id: 'a1', label: 'Alpha', group: null }, { id: 'a2', label: 'Beta', group: null }],
rows: [{ member: { label: '2026-07-01' }, values: [5, ''] }, { member: { label: '2026-07-02' }, values: ['', 9] }],
};
expect(toCSV(matrix)).toBe(
'Parameter,Total attempts\n\nDate,Alpha,Beta\n2026-07-01,5,\n2026-07-02,,9',
);
});
it('adds a Group header row when subjects are columns', () => {
const matrix = {
corner: 'Date',
context: { label: 'Parameter', value: 'Total attempts' },
groupAxis: 'col',
columns: [{ id: 'a1', label: 'Alpha', group: 'Control' }, { id: 'a2', label: 'Beta', group: 'Drug' }],
rows: [{ member: { label: '2026-07-01' }, values: [5, 9] }],
};
expect(toCSV(matrix)).toBe(
'Parameter,Total attempts\n\nGroup,Control,Drug\nDate,Alpha,Beta\n2026-07-01,5,9',
);
});
it('adds a leading Group column when subjects are rows', () => {
const matrix = {
corner: 'Subject',
context: { label: 'Date', value: '2026-07-01' },
groupAxis: 'row',
columns: [{ id: 'p1', label: 'Total attempts', group: 'metrics' }],
rows: [
{ member: { label: 'Alpha', group: 'Control' }, values: [5] },
{ member: { label: 'Beta', group: 'Drug' }, values: [9] },
],
};
expect(toCSV(matrix)).toBe(
'Date,2026-07-01\n\nGroup,Subject,Total attempts\nControl,Alpha,5\nDrug,Beta,9',
);
});
it('escapes commas, quotes, and newlines per RFC 4180', () => {
const matrix = {
corner: 'Date',
context: { label: 'Parameter', value: 'Note, field' },
groupAxis: null,
columns: [{ id: 'a1', label: 'he said "hi"', group: null }],
rows: [{ member: { label: '2026-07-01' }, values: ['line1\nline2'] }],
};
expect(toCSV(matrix)).toBe(
'Parameter,"Note, field"\n\nDate,"he said ""hi"""\n2026-07-01,"line1\nline2"',
);
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx jest tests/dataExport.test.js -t "toCSV"`
Expected: FAIL — old `toCSV` emits `Date,Alpha,Beta\n...` with no context line.
- [ ] **Step 3: Replace the implementation**
In `frontend/src/lib/dataExport.js`, **delete the old `toCSV` function** and its doc comment, and replace with:
```js
// Serialize a matrix to an RFC-4180-ish CSV string (LF line endings):
// <pinned label>,<pinned value>
// (blank line)
// [Group,<group per subject column>] -- only when groupAxis === 'col'
// <corner>[,Group?],<column headers...>
// <row label>[,group?],<values...>
export function toCSV(matrix) {
const lines = [];
lines.push([matrix.context.label, matrix.context.value].map(escapeCsvCell).join(','));
lines.push('');
if (matrix.groupAxis === 'col') {
const groupRow = ['Group', ...matrix.columns.map((c) => c.group ?? '—')];
lines.push(groupRow.map(escapeCsvCell).join(','));
}
const header = matrix.groupAxis === 'row'
? ['Group', matrix.corner, ...matrix.columns.map((c) => c.label)]
: [matrix.corner, ...matrix.columns.map((c) => c.label)];
lines.push(header.map(escapeCsvCell).join(','));
for (const r of matrix.rows) {
const lead = matrix.groupAxis === 'row'
? [r.member.group ?? '—', r.member.label]
: [r.member.label];
lines.push([...lead, ...r.values].map(escapeCsvCell).join(','));
}
return lines.join('\n');
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npx jest tests/dataExport.test.js`
Expected: PASS (all suites, including the unchanged `csvFilename` and `extractValue` suites).
- [ ] **Step 5: Commit**
```bash
git add frontend/src/lib/dataExport.js frontend/tests/dataExport.test.js
git commit -m "feat(export): toCSV emits context line + subject group cell"
```
---
### Task 5: Rewrite ExportDataModal with axis + group controls
**Files:**
- Modify: `frontend/src/components/ExportDataModal.jsx` (full rewrite of the body; keep `triggerDownload`)
- Test: `frontend/tests/ExportDataModal.test.jsx` (create)
**Interfaces:**
- Consumes: `EXPORT_DIMENSIONS`, `dimLabel`, `otherDimension`, `listDimensionMembers`, `buildMatrix`, `toCSV`, `csvFilename` from `../lib/dataExport`; `experimentsApi.getDailyStatuses` from `../api/client`.
- New props: `subjectTemplate` (array of `{ fieldId, label, active }`), `groupField` (string, the experiment's saved default). Existing props unchanged: `experimentTitle`, `experimentId`, `dailyTemplate`, `animals`, `onClose`.
- Produces: rendered modal. Default selection reproduces the legacy layout.
- [ ] **Step 1: Write the failing component test**
Create `frontend/tests/ExportDataModal.test.jsx`:
```jsx
import React from 'react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import ExportDataModal from '../src/components/ExportDataModal';
import * as client from '../src/api/client';
jest.mock('../src/api/client', () => ({
experimentsApi: { getDailyStatuses: jest.fn() },
}));
const animals = [
{ id: 'a1', animal_name: 'Alpha', animal_id_string: 'R-001', subject_info: { grp: 'Control' } },
{ id: 'a2', animal_name: 'Beta', animal_id_string: 'R-002', subject_info: { grp: 'Drug' } },
];
const statuses = [
{ animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 5 } },
{ animal_id: 'a2', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 9 } },
];
const subjectTemplate = [{ fieldId: 'grp', label: 'Group', active: true }];
beforeEach(() => {
jest.clearAllMocks();
client.experimentsApi.getDailyStatuses.mockResolvedValue(statuses);
});
function renderModal(props = {}) {
return render(
<ExportDataModal
experimentId="exp-1"
experimentTitle="My Study"
dailyTemplate={[]}
animals={animals}
subjectTemplate={subjectTemplate}
groupField="__none__"
onClose={jest.fn()}
{...props}
/>,
);
}
describe('ExportDataModal', () => {
it('defaults to Rows=Date, Columns=Subject and shows the preview', async () => {
renderModal();
expect(await screen.findByLabelText('Rows')).toHaveValue('date');
expect(screen.getByLabelText('Columns')).toHaveValue('subject');
await waitFor(() => expect(screen.getByText(/1 date.*2 subjects/i)).toBeInTheDocument());
});
it('hides the Group by control until Subject is an axis', async () => {
renderModal();
await screen.findByLabelText('Rows');
// subject is a column by default -> Group by visible
expect(screen.getByLabelText('Group by')).toBeInTheDocument();
// move subject off both axes: Rows=Date, Columns=Parameter -> subject pinned
fireEvent.change(screen.getByLabelText('Columns'), { target: { value: 'parameter' } });
await waitFor(() => expect(screen.queryByLabelText('Group by')).not.toBeInTheDocument());
});
it('keeps row and column dimensions distinct', async () => {
renderModal();
const rows = await screen.findByLabelText('Rows');
fireEvent.change(rows, { target: { value: 'subject' } }); // collides with column=subject
await waitFor(() => expect(screen.getByLabelText('Columns')).not.toHaveValue('subject'));
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx jest tests/ExportDataModal.test.jsx`
Expected: FAIL — modal has no `Rows`/`Columns`/`Group by` labels yet.
- [ ] **Step 3: Rewrite the component**
Replace the body of `frontend/src/components/ExportDataModal.jsx` (keep the top `triggerDownload` helper exactly as-is) with this component:
```jsx
import React, { useEffect, useMemo, useState } from 'react';
import { experimentsApi } from '../api/client';
import Button from './ui/Button';
import Alert from './ui/Alert';
import {
EXPORT_DIMENSIONS, dimLabel, otherDimension,
listDimensionMembers, buildMatrix, toCSV, csvFilename,
} from '../lib/dataExport';
const PARAM_GROUP_LABELS = { metrics: 'Session metrics', daily: 'Daily record fields' };
export default function ExportDataModal({
experimentTitle, experimentId, dailyTemplate, animals, subjectTemplate = [], groupField = '__none__', onClose,
}) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [statuses, setStatuses] = useState([]);
const [rowDim, setRowDim] = useState('date');
const [colDim, setColDim] = useState('subject');
const [pinnedId, setPinnedId] = useState(null);
const [groupBy, setGroupBy] = useState(groupField ?? '__none__');
useEffect(() => {
let alive = true;
setLoading(true);
setError(null);
experimentsApi.getDailyStatuses(experimentId, {})
.then((data) => { if (alive) setStatuses(data); })
.catch((err) => { if (alive) setError(err.message); })
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [experimentId]);
const pinnedDim = otherDimension(rowDim, colDim);
const subjectIsAxis = rowDim === 'subject' || colDim === 'subject';
const effGroup = subjectIsAxis ? groupBy : '__none__';
const ctx = useMemo(() => ({ statuses, animals, dailyTemplate }), [statuses, animals, dailyTemplate]);
// Members of the pinned dimension populate its value dropdown.
const pinnedMembers = useMemo(
() => listDimensionMembers(pinnedDim, { ...ctx, groupField: effGroup }),
[pinnedDim, ctx, effGroup],
);
const effPinnedId = pinnedId ?? pinnedMembers[0]?.id ?? null;
const pinnedMember = pinnedMembers.find((m) => m.id === effPinnedId) ?? pinnedMembers[0] ?? null;
const matrix = useMemo(
() => (pinnedMember
? buildMatrix({ rowDim, colDim, pinnedMember, groupField: effGroup }, ctx)
: { columns: [], rows: [], context: { label: '', value: '' }, corner: '', groupAxis: null }),
[rowDim, colDim, pinnedMember, effGroup, ctx],
);
const hasData = matrix.rows.length > 0;
// When the user changes an axis, keep the two distinct and reset the pinned pick.
function changeRowDim(next) {
setRowDim(next);
if (colDim === next) setColDim(otherDimension(next, null) ?? EXPORT_DIMENSIONS.find((d) => d.dim !== next).dim);
setPinnedId(null);
}
function changeColDim(next) {
setColDim(next);
if (rowDim === next) setRowDim(EXPORT_DIMENSIONS.find((d) => d.dim !== next).dim);
setPinnedId(null);
}
function handleExport() {
if (!hasData || !pinnedMember) return;
triggerDownload(csvFilename(experimentTitle, pinnedMember.label), toCSV(matrix));
onClose();
}
if (loading) return <p className="text-sm text-gray-400" aria-live="polite">Loading data</p>;
if (error) return (
<div className="space-y-4">
<Alert type="error" message={error} />
<div className="flex justify-end"><Button variant="secondary" onClick={onClose}>Close</Button></div>
</div>
);
const selectCls = 'w-full border border-gray-200 rounded px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400';
const colOptions = EXPORT_DIMENSIONS.filter((d) => d.dim !== rowDim);
return (
<div className="space-y-4">
<p className="text-sm text-gray-500">
Choose what goes on the rows and columns. The remaining dimension is fixed to one value, shown at the top of the file.
</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="export-rows" className="block text-xs font-medium text-gray-500 mb-1">Rows</label>
<select id="export-rows" value={rowDim} onChange={(e) => changeRowDim(e.target.value)} className={selectCls}>
{EXPORT_DIMENSIONS.map((d) => <option key={d.dim} value={d.dim}>{d.label}</option>)}
</select>
</div>
<div>
<label htmlFor="export-cols" className="block text-xs font-medium text-gray-500 mb-1">Columns</label>
<select id="export-cols" value={colDim} onChange={(e) => changeColDim(e.target.value)} className={selectCls}>
{colOptions.map((d) => <option key={d.dim} value={d.dim}>{d.label}</option>)}
</select>
</div>
</div>
<div>
<label htmlFor="export-pinned" className="block text-xs font-medium text-gray-500 mb-1">
Fixed: {dimLabel(pinnedDim)}
</label>
<select
id="export-pinned"
value={effPinnedId ?? ''}
onChange={(e) => setPinnedId(e.target.value)}
className={selectCls}
>
{pinnedDim === 'parameter'
? groupParamMembers(pinnedMembers).map((g) => (
<optgroup key={g.group} label={PARAM_GROUP_LABELS[g.group] ?? g.group}>
{g.items.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
</optgroup>
))
: pinnedMembers.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
</div>
{subjectIsAxis && (
<div>
<label htmlFor="export-group" className="block text-xs font-medium text-gray-500 mb-1">Group by</label>
<select id="export-group" value={groupBy} onChange={(e) => setGroupBy(e.target.value)} className={selectCls}>
<option value="__none__"> None </option>
<option value="__name__">Name</option>
<option value="__id__">Subject ID</option>
{subjectTemplate.filter((f) => f.active).map((f) => (
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
))}
</select>
</div>
)}
<p className="text-xs text-gray-400">
{hasData
? `${matrix.rows.length} ${matrix.corner.toLowerCase()}${matrix.rows.length !== 1 ? 's' : ''} × ${matrix.columns.length} ${dimLabel(colDim).toLowerCase()}${matrix.columns.length !== 1 ? 's' : ''}`
: 'No data for this selection yet.'}
</p>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button onClick={handleExport} disabled={!hasData}>Export CSV</Button>
</div>
</div>
);
}
// Group parameter members by their .group for <optgroup> rendering, preserving order.
function groupParamMembers(members) {
const out = [];
for (const m of members) {
let g = out.find((x) => x.group === m.group);
if (!g) { g = { group: m.group, items: [] }; out.push(g); }
g.items.push(m);
}
return out;
}
```
Note: the preview test matches `/1 date.*2 subjects/i` — the corner is `Date` (→ "date"/"dates") and `dimLabel(colDim)` is `Subject` (→ "subject"/"subjects"). Confirm the string reads "1 date × 2 subjects".
- [ ] **Step 4: Run test to verify it passes**
Run: `npx jest tests/ExportDataModal.test.jsx`
Expected: PASS (all three cases).
- [ ] **Step 5: Commit**
```bash
git add frontend/src/components/ExportDataModal.jsx frontend/tests/ExportDataModal.test.jsx
git commit -m "feat(export): modal gains assignable axes + group-by control"
```
---
### Task 6: Wire new props from ExperimentDetail
**Files:**
- Modify: `frontend/src/pages/ExperimentDetail.jsx` (the `<ExportDataModal ... />` render, ~line 414)
**Interfaces:**
- Consumes: `subjectTemplate` state and `groupField` state (both already present in `ExperimentDetail`).
- Produces: passes `subjectTemplate={subjectTemplate}` and `groupField={groupField}` to `ExportDataModal`.
- [ ] **Step 1: Add the two props**
In `frontend/src/pages/ExperimentDetail.jsx`, change the `<ExportDataModal>` element to:
```jsx
<ExportDataModal
experimentId={id}
experimentTitle={experiment.title}
dailyTemplate={dailyTemplate}
animals={animals}
subjectTemplate={subjectTemplate}
groupField={groupField}
onClose={() => setShowExport(false)}
/>
```
- [ ] **Step 2: Run the full frontend test suite**
Run: `npm test`
Expected: PASS — full suite green, including `dataExport` and `ExportDataModal`.
- [ ] **Step 3: Manual smoke check**
Run: `npm run dev`, open an experiment with daily data, click **Export data**. Verify: default download matches the old date×subject×parameter layout; switching Rows/Columns updates the preview; the fixed-value dropdown lists the third dimension; a group-by choice adds the Group row/column and the top context line reads e.g. `Subject,Mouse-01`.
- [ ] **Step 4: Commit**
```bash
git add frontend/src/pages/ExperimentDetail.jsx
git commit -m "feat(export): pass subjectTemplate + saved group field to export modal"
```
---
## Self-Review Notes
- **Spec coverage:** assignable axes (Tasks 13, 5) · pinned third dimension + value picker (Tasks 3, 5) · beginning-only context line (Task 4) · subject group cell on the subject axis, clustered by group (Tasks 24) · Group-by dropdown defaulting to saved field, shown only when Subject is an axis (Tasks 56) · keep-subjects/drop-blank-date-and-param rule (Task 3) · default reproduces legacy layout (Tasks 3, 5). All covered.
- **Type consistency:** member shape `{ id, label, ... }` and matrix shape `{ context, corner, groupAxis, columns:[{id,label,group}], rows:[{member,values}] }` are used identically across Tasks 35.
- **Out of scope (per spec):** multi-value stacking, footer repeat, experiment-level metadata block, persisting axis choices.
@@ -0,0 +1,235 @@
# Numerical CSV-analysis buckets, persisted per animal
**Date:** 2026-05-01
**Scope:** `frontend/src/components/CsvAnalysis.jsx`, `frontend/src/lib/csvAnalysis.js`, `frontend/src/api/client.js`, `frontend/src/pages/DailyStatusDetail.jsx`, `backend/prisma/schema.prisma`, backend animal route.
## Goal
In the daily-status CSV-analysis flow, let the user define an extra type of bucket — a **numerical** bucket — alongside the existing note-classification buckets (Success / Failure / Other / custom). A numerical bucket plots its assigned notes' numeric portion against the CSV frame column (or timestamp as fallback) and reports an average. The bucket structure and per-bucket note assignments persist on the `Animal` so subsequent CSV uploads for the same subject don't need to be re-classified from scratch.
## Section 0 — Preconditions
Before applying the schema migration, back up the live Postgres database to a sibling `archived/` folder.
```bash
mkdir -p /home/sam/docker-images/archived
docker exec experiments_postgres pg_dump -U expuser -d experiments_db -F c \
> /home/sam/docker-images/archived/experiments_db_$(date +%Y%m%d_%H%M%S).dump
```
- Verify the dump file size is greater than 0 bytes before proceeding.
- Restore command (if needed):
```bash
docker exec -i experiments_postgres pg_restore -U expuser -d experiments_db -c \
< /home/sam/docker-images/archived/<dumpfile>
```
## Section 1 — Data model
### Schema change
Add one nullable JSON column to `Animal`:
```prisma
model Animal {
id String @id @default(uuid())
experiment_id String
animal_id_string String
animal_name String
subject_info Json?
csv_analysis_config Json? // <— new
experiment Experiment @relation(fields: [experiment_id], references: [id], onDelete: Cascade)
daily_statuses DailyStatus[]
@@map("animals")
}
```
### Stored shape
```jsonc
{
"buckets": [
{ "name": "Success", "type": "note", "notes": ["S", "S1"] },
{ "name": "Failure", "type": "note", "notes": ["F"] },
{ "name": "Other", "type": "note", "notes": [] },
{ "name": "Lick latency", "type": "numerical", "notes": ["L25", "L30"] }
]
}
```
### Rules
- `type: "note"` — existing behavior (counted, run-length-encoded, contributes to daily-status summary).
- `type: "numerical"` — plotted vs. frame (or timestamp); reports average; **excluded** from category counts, consecutive runs, and the "Save metrics to daily status" calculation.
- The three default note buckets (Success, Failure, Other) are always present: seeded into `buckets` on first save and undeletable in the UI.
- Custom buckets (both note and numerical) can be removed via an `` control on the bucket. Removal puts any notes that were assigned to that bucket back into Unassigned for the current session and drops the bucket from `buckets[]` on next save.
- `notes` arrays persist as raw trimmed strings. A new CSV upload looks up each unique note in the persisted `notes` arrays to auto-classify; any leftover notes appear as **Unassigned**.
- A single note value belongs to at most one bucket. Assigning a note to bucket B removes it from any other bucket's `notes[]` first.
- Bucket names are unique within `buckets`. Renaming is out of scope for v1.
- `csv_analysis_config` may be `null` (animal never had CSV analysis run); UI falls back to `DEFAULT_BUCKETS` in that case.
### Backend API
- `GET /api/animals/:id` — already returns the full animal row. `csv_analysis_config` is included automatically.
- `PATCH /api/animals/:id/csv-config` — body `{ buckets: [...] }`. Validates:
- `buckets` is an array.
- Each entry has non-empty trimmed `name`, `type ∈ {"note","numerical"}`, `notes` is `string[]`.
- `name` values are unique within the array.
- The three default note buckets (Success/Failure/Other) are present.
Replaces the column atomically. Returns updated animal. Audit-logged via existing middleware.
## Section 2 — Frontend changes
### `CsvAnalysis.jsx` (the main refactor)
1. **New prop `animal`** — passed from `DailyStatusDetail`. Used to read & write `csv_analysis_config`.
2. **State refactor**: replace the parallel `categories: string[]` + `customCategories: string[]` + `classifications: { [note]: category }` with a single source of truth:
```js
const [buckets, setBuckets] = useState([]);
// bucket: { name: string, type: 'note' | 'numerical', notes: string[] }
```
Derive `classifications`, `categories`, `customCategories` from `buckets` for compatibility with downstream code (`runAnalysis`, color helpers).
3. **Seeding on entering classify phase**:
- If `animal.csv_analysis_config?.buckets` exists, use it.
- Otherwise initialize from `DEFAULT_BUCKETS` (Success/Failure/Other, all `type: "note"`, empty `notes`).
- Auto-classify: any `uniqueNote` whose value appears in some bucket's `notes[]` is considered already-assigned and does not appear in Unassigned.
4. **"+ Add field" button** (replaces today's "+ New category"):
- Inline form: `<input name>` + radio toggle `( ) Note ( ) Numerical`.
- On submit, appends `{ name, type, notes: [] }` to `buckets`.
5. **`CategoryBucket` extension**:
- Accepts `type` prop.
- Color allocation: `getCategoryColors` is updated to take the full bucket list and assign `EXTRA_COLORS` round-robin across all custom buckets (note + numerical combined), so adding numerical buckets does not shift the colors of existing note buckets.
- Numerical buckets render with a "numerical" header badge to distinguish them visually.
- Numerical buckets show an "**Add all unassigned**" button that bulk-moves every currently-unassigned unique note into this bucket's `notes[]`.
- Custom buckets (note or numerical) show an `` remove control on hover.
- In results phase, numerical buckets show a footer line: `Avg: 24.3 · 47/50 rows plottable`.
6. **`compute()`**:
- Note-type buckets: pass through `runAnalysis(rows, timestampCol, noteCol, classifications)` as today, but `classifications` is built only from `note`-type buckets (numerical-bucket assignments are excluded so they don't pollute counts/runs).
- Numerical-type buckets: for each, call new helper `computeNumericSeries(rows, frameCol || timestampCol, noteCol, bucket)` returning `{ points: [{x, value, raw}], avg, skipped, total, xLabel }`.
- After successful analysis, call `animalsApi.updateCsvConfig(animal.id, { buckets })`. Failure surfaces a non-blocking toast/inline error; analysis results still render.
7. **Results phase additions**:
- Existing tables (Category Counts, Consecutive Runs) iterate `buckets.filter(b => b.type === 'note')` — numerical buckets are excluded.
- New "Numerical Plots" section after `RunSequenceView`. One panel per numerical bucket, containing:
- Bucket name, average, skipped-count.
- Recharts `<LineChart>` with `XAxis dataKey="x"` (label = `xLabel`) and a single `<Line dataKey="value">`. Color from `getCategoryColors(bucket.name, customCategories)`.
- If a numerical bucket has zero plottable rows, render `"No plottable values — N of N note rows had no extractable number."` instead of a chart.
### `frontend/src/lib/csvAnalysis.js`
```js
// Extract first signed decimal from a string. Returns null if none found.
export function extractNumber(str) {
if (str == null) return null;
const m = String(str).match(/-?\d+(\.\d+)?/);
return m ? parseFloat(m[0]) : null;
}
// Build a numerical series for one bucket.
// xColName: frameCol if set, else timestampCol. xLabel reflects which.
export function computeNumericSeries(rows, xColName, noteCol, bucket) {
const noteSet = new Set(bucket.notes);
const points = [];
let skipped = 0;
let total = 0;
for (const r of rows) {
const note = String(r[noteCol] ?? '').trim();
if (!noteSet.has(note)) continue;
total++;
const value = extractNumber(note);
const x = parseFloat(r[xColName]);
if (value == null || Number.isNaN(x)) { skipped++; continue; }
points.push({ x, value, raw: note });
}
points.sort((a, b) => a.x - b.x);
const avg = points.length === 0
? null
: points.reduce((s, p) => s + p.value, 0) / points.length;
return { points, avg, skipped, total, xLabel: xColName };
}
```
### `frontend/src/api/client.js`
Add to `animalsApi`:
```js
updateCsvConfig: (animalId, config) =>
request(`/animals/${animalId}/csv-config`, { method: 'PATCH', body: JSON.stringify(config) }),
```
### `frontend/src/pages/DailyStatusDetail.jsx`
Single change: pass `animal={status.animal}` to `<CsvAnalysis>`.
## Section 3 — Behavior details
- **Frame column** stays optional. If unset when running analysis with ≥1 numerical bucket, x-axis falls back to `timestampCol`. Chart axis label and the panel header reflect which axis is in use.
- **Letter stripping** uses `/-?\d+(\.\d+)?/` (first signed decimal in the string). Notes with no extractable number are counted as `skipped`.
- **Persistence trigger**: config is saved after `compute()` succeeds. Editing buckets in classify phase without running analysis does not persist.
- **Multiple numerical buckets**: fully supported. Each gets its own chart and its own average.
- **Same note in multiple buckets**: prevented at the UI level — assigning a note to a bucket removes it from any other bucket. Backend does not enforce this (UI invariant).
## Section 4 — Error handling
| Failure | Behavior |
|-----------------------------------------------|-------------------------------------------------------------------------------------------|
| Save config (PATCH) fails | Local state unchanged; non-blocking inline error in classify/results UI; analysis still renders. |
| Numerical bucket has zero plottable rows | Panel shows "No plottable values — N of N rows had no extractable number." No crash. |
| Frame column absent + numerical bucket exists | Falls back to timestamp; chart axis label reflects this. |
| Migration | Run the Section 0 backup first; abort if dump file size is 0 bytes. |
| Validation rejection on PATCH | UI shows server error message; user can re-edit and retry. |
## Section 5 — Testing
### Frontend unit (Vitest, `frontend/src/lib/`)
- `extractNumber()`:
- `"L25.5"` → 25.5
- `"R-12"` → -12
- `"trial_007"` → 7
- `"3.2e2"` → 3.2 (loses scientific notation — accepted limitation)
- `"42 ms"` → 42
- `"abc"` → null
- `""` → null
- `null` → null
- `computeNumericSeries()`:
- All rows valid → correct points + average + skipped=0
- Some unparseable → correct skipped count, points exclude bad rows
- All unparseable → empty points, avg=null, skipped=total
- `xColName` missing from rows → all skipped (NaN x)
- Bucket with empty `notes[]` → empty points, total=0
- Output sorted ascending by x
### Backend unit
- `PATCH /animals/:id/csv-config`:
- Valid body → 200, persisted, audit log row written
- Empty bucket name → 400
- Duplicate names → 400
- Invalid `type` value → 400
- `notes` not an array → 400
- Missing default bucket (e.g. no "Success") → 400
### Integration / manual
- Upload a CSV → create one numerical bucket → assign notes → run analysis → reload page → upload same CSV → confirm bucket is pre-populated and assignments are restored.
- Same flow with a CSV containing genuinely-new note values → confirm new notes show as Unassigned, old ones do not.
- Numerical bucket with frame col present → chart x-axis labeled with frame col name.
- Numerical bucket with frame col cleared → chart x-axis labeled with timestamp col name.
## Section 6 — Out of scope (v1)
- Bucket renaming after creation.
- Y-axis range customization, multi-line overlays.
- Showing the chart for past `DailyAnalysis` records (frame data isn't stored; user re-drops the CSV to plot).
- Per-experiment defaults (config is per-animal only).
- Editing config from a settings page; config is only edited via the CSV-analysis flow.
@@ -0,0 +1,113 @@
# Cross-subject metrics plot — interaction enhancements
**Date:** 2026-06-01
**Component:** `frontend/src/components/AnalysisCharts.jsx``ExperimentAnalysisCharts`
**Rendered on:** `frontend/src/pages/ExperimentDetail.jsx`
## Summary
Add four client-side interaction features to the "Cross-subject metrics" plot (two
Recharts `LineChart`s — count-per-session and success-rate — where each line is one
subject, colored by group):
1. A toggleable sidebar listing subjects grouped by their group, with per-subject and
per-group checkboxes to show/hide each subject's line.
2. Clicking a subject's legend entry toggles that subject's line visibility.
3. The hover tooltip orders its rows to match the vertical stacking of the points
(highest value at top).
4. Hovering a subject's legend entry (or sidebar row) highlights that subject's line.
## Constraints
- **Frontend only.** No backend, API, or database changes. All state is client-side
React state, not persisted.
- **No regressions.** The existing global controls (X axis, Color by, tick step), the
per-chart customize bars (size, Y range, X zoom, per-chart X-field override), the
Y-metric selector, and the sibling `SubjectAnalysisCharts` component are untouched.
- Scope is limited to `ExperimentAnalysisCharts`.
## Visibility model (decided)
Visibility is **global per-subject**: toggling a subject anywhere — sidebar checkbox,
either chart's legend — hides or shows it in **both** charts simultaneously. "Per plot"
in the original request resolves to per-subject, not per-chart.
## Shared state (lifted into `ExperimentAnalysisCharts`)
- `hiddenSubjects: Set<animalId>` — empty = all visible. Global across both charts.
Stale ids (subject removed) are harmless: they match no line.
- `highlightedSubject: animalId | null` — transient hover state, shared by both charts
and the sidebar.
- `sidebarOpen: boolean` — default `false`.
Helpers: `toggleHidden(id)`, `setGroupHidden(groupSubjectIds, hidden)`, `showAll()`,
`setHighlight(id | null)`.
## Layout
A toggle button (`▸ Subjects`) is added to the existing global-controls row. When open,
the card body becomes a flex row:
- Charts area: `flex-1`; the existing `ResponsiveContainer width="100%"` re-flows the
charts narrower automatically.
- Sidebar panel: ~210px fixed width, `border-l`, top-aligned with the card.
```
┌─ Cross-subject metrics ───────── X axis▾ Color by▾ Tick [▸ Subjects] ─┐
│ │ SUBJECTS Show all │
│ ┌──────────────────────────────┐ │ ┌───────────────────────┐ │
│ │ Count chart (re-flows) │ │ │ ▣ Group A (3)│ │
│ └──────────────────────────────┘ │ │ ☑ ● Mouse-A1 │ │
│ ┌──────────────────────────────┐ │ │ ☑ ● Mouse-A2 │ │
│ │ Success-rate chart │ │ │ ☐ ● Mouse-A3 │ │
│ └──────────────────────────────┘ │ │ ▢ Group B (2)│ │
│ │ │ ☐ ● Mouse-B1 │ │
│ │ └───────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Sidebar contents
- **Grouping** reuses the current `Color by` field via the existing `lines[].group`.
With `Color by = none`, all subjects fall under a single "—" group.
- **Subject row**: checkbox (checked = visible) + color swatch (`lines[].color`) + name.
Hovering the row sets `highlightedSubject`.
- **Group row**: tri-state checkbox — checked if all in group visible, empty if all
hidden, indeterminate if mixed. Clicking toggles the whole group. Shows group name +
count.
- **"Show all"** header link — clears `hiddenSubjects`; shown only when something is
hidden.
## Behavior details
1. **Visibility** — every subject `<Line>` is rendered always (in both charts) with
`hide={hiddenSubjects.has(l.id)}` instead of filtering the `lines` array. Recharts
keeps a greyed, still-clickable legend entry for hidden lines and excludes them from
the tooltip payload automatically.
2. **Legend click**`<Legend onClick={(o) => toggleHidden(o.dataKey)} />` on both
charts. `dataKey` is `l.id` (lines use `dataKey={l.id} name={l.id}`).
3. **Tooltip ordering** — the existing custom `ExpTooltip` sorts `payload` by `value`
descending before rendering its rows, so the topmost line is first. Null/hidden
subjects are already excluded. Applies to both the count and rate tooltips.
4. **Legend hover highlight** — `<Legend onMouseEnter={(o) => setHighlight(o.dataKey)}
onMouseLeave={() => setHighlight(null)} />`. Each line's `strokeWidth` is `3.5` when
`highlightedSubject === l.id`, else `2` (other lines unchanged — "thicken hovered
only"). Highlight applies in **both** charts and is also driven by sidebar-row hover.
## Testing
- Manual verification in the running app (`ExperimentDetail` page with an experiment
that has animals and saved analysis metrics):
- Open/close sidebar; charts re-flow.
- Toggle a subject checkbox → line disappears in both charts; legend entry greys out.
- Group checkbox toggles all members; shows indeterminate when mixed.
- "Show all" clears hidden state.
- Click a legend entry → toggles in both charts.
- Hover tooltip rows are ordered highest-value-first.
- Hover a legend entry / sidebar row → that line thickens in both charts.
- Confirm existing controls (X axis, Color by, tick step, per-chart customize, Y-metric)
still work.
```
@@ -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.
@@ -0,0 +1,148 @@
# Experiment Data Export (CSV) — Design
**Date:** 2026-07-06
**Status:** Approved, ready for implementation plan
## Goal
Let a user on the experiment page export a chosen daily parameter as a CSV
file shaped as a matrix: **each row is a date, each column is a subject**, each
cell is that subject's value for the parameter on that date. Every daily
parameter must be exportable, including the session metrics derived from
`analysis_summary`.
## Scope
- **In:** CSV export, one parameter per file, client-side generation, a picker
UI on the experiment page.
- **Out (deferred):** true `.xlsx` output (would add a client-side library such
as SheetJS later; the pure helpers stay format-agnostic so the workbook
builder can be added without reshaping the data), multi-parameter / multi-sheet
export, a backend export endpoint.
## Approach
Fully client-side. The existing `GET /api/experiments/:id/daily-statuses`
endpoint already returns every field required per status — builtin fields
(`experiment_description`, `vitals`, `treatment`, `notes`), `custom_fields`, and
`analysis_summary`. The experiment page currently fetches only the
`analysisOnly=true` subset (for charts); the export modal fetches the **full**
set on open, pivots it into a date×subject matrix in the browser, serializes to
CSV, and triggers a download. No backend change.
Rejected alternative: a backend `/export` route streaming CSV. It only pays off
for very large datasets or server-side xlsx, at the cost of a new route, tests,
and duplicating the template/parameter logic on the server. Research datasets
here are small (tens of subjects, hundreds of dates), so client-side is simpler
and unit-testable.
## Exportable parameters
The picker is grouped into two sections.
### Session metrics (from `analysis_summary`)
- **Total attempts** → `analysis_summary.total`
- **Success rate** → `analysis_summary.success_rate`, exported as the **raw
01 fraction** (not multiplied to a percentage)
- One entry per **dynamic count category** found in the data →
`analysis_summary.counts[category]`. Categories are enumerated by scanning the
fetched statuses (e.g. `Success`, `Failure`, `Other`, and any others present).
### Daily record fields (from the daily template)
- **Every** field defined in the daily template, including ones currently
hidden/inactive, since historical data may exist for them.
- Builtin fields read from `status[field.key]`; custom fields read from
`status.custom_fields[field.fieldId]` (same accessor logic as
`AnalysisCharts.numericFieldVal`).
- Values export as-is: text fields (e.g. `notes`, `vitals`) as strings, numeric
fields as numbers. No numeric coercion is required for export.
## Matrix shape
- **Rows** = every date that has at least one value for the chosen parameter
across any subject, sorted ascending, formatted `YYYY-MM-DD`. The first column
header is `Date`.
- **Columns** = all enrolled animals, ordered by `animal_name`. A subject is a
column even if it has no value for the parameter (blank cells). On duplicate
`animal_name`, disambiguate the header with the subject's `animal_id_string`
(e.g. `Rat A (R-001)`).
- **Cell** = the subject's value for the parameter on that date. Assumes one
daily status per (subject, date); if duplicates are ever present, the first in
date-ascending order wins.
- Empty result (no dates have any value) is surfaced in the UI as a disabled
export / "no data for this parameter" note rather than producing an empty file.
## Components
### `frontend/src/lib/dataExport.js` (new, pure — no React, no I/O)
Mirrors the existing `frontend/src/lib/crossSubjectChart.js` pure-helper pattern.
- `listExportableParams(dailyTemplate, statuses)` → grouped, ordered parameter
descriptors. Each descriptor carries an id, label, group (`'metrics'` |
`'daily'`), and enough info to extract a value from a status. Session-metric
categories are derived from `statuses`; template params from `dailyTemplate`.
- `extractValue(status, param)` → the raw cell value for one status/param (or
`null`/blank when absent). Encapsulates the builtin-vs-custom and
`analysis_summary` accessor logic.
- `buildMatrix(statuses, animals, param)``{ columns, rows }` where `columns`
are the ordered subject headers and `rows` are `{ date, values: [...] }`
aligned to `columns`, including blank cells; rows limited to dates with data.
- `toCSV(matrix)` → CSV string. Escapes any field containing a comma, double
quote, or newline per RFC 4180 (wrap in quotes, double embedded quotes).
### `frontend/src/components/ExportDataModal.jsx` (new)
- On open, fetches the full daily-statuses set via
`experimentsApi.getDailyStatuses(id, {})` (no `analysisOnly`).
- Renders a grouped `<select>` of exportable parameters (Session metrics /
Daily record fields).
- Shows a small summary line: "N dates × M subjects".
- **Export CSV** button builds a `Blob`, creates an object URL, clicks a
temporary anchor, and revokes the URL. Filename:
`<sanitized-experiment-title>-<sanitized-param-label>.csv`.
- Handles loading and error states for the fetch.
### `frontend/src/pages/ExperimentDetail.jsx` (edit)
- Add an **Export data** button in the header area (near **+ Add Animal**) that
opens `ExportDataModal`, passing the experiment id, title, `dailyTemplate`,
and `animals`.
## Data flow
1. User clicks **Export data** → modal opens.
2. Modal fetches full statuses (all fields, all subjects).
3. `listExportableParams(dailyTemplate, statuses)` builds the picker options.
4. User selects a parameter → `buildMatrix(statuses, animals, param)` computes
the date×subject matrix; summary line updates.
5. User clicks **Export CSV**`toCSV(matrix)` → Blob download.
## Error handling
- Fetch failure: show an inline error in the modal; no download.
- Parameter with no data across all subjects/dates: disable export and show a
brief note.
- CSV correctness relies on `toCSV` escaping; covered by tests.
## Testing
Unit tests for `dataExport.js`, following `frontend/tests/crossSubjectChart.test.js`:
- `listExportableParams`: includes all template fields (incl. inactive), the two
fixed session metrics, and each dynamic count category discovered in the data;
correct grouping/ordering.
- `extractValue`: builtin vs custom field access; `total`, `success_rate` (raw
01), and `counts[category]`; missing values → blank/null.
- `buildMatrix`: rows only for dates with data, ascending; all subjects as
columns with blanks; duplicate-name disambiguation; one-status-per-cell.
- `toCSV`: escaping of commas, quotes, and newlines; header row; blank cells.
## Out of scope / future
- `.xlsx` output via a client-side workbook library (helpers already
format-agnostic).
- Selecting multiple parameters (multi-sheet xlsx or multi-file/zip CSV).
- Server-side export for very large datasets.
@@ -0,0 +1,101 @@
# Configurable Data Export — Design
**Date:** 2026-07-19
**Status:** Approved (design)
**Area:** `frontend/src/lib/dataExport.js`, `frontend/src/components/ExportDataModal.jsx`, `frontend/tests/dataExport.test.js`
## Problem
Today's data export has a fixed layout: each **row is a date**, each **column is a subject**, and the user picks **one parameter** whose value fills every cell (`buildMatrix` in `dataExport.js`). Users want to:
1. Choose which dimension goes on rows and which on columns (fully assignable axes).
2. See the pinned/third dimension labeled at the top of the file ("group information at the beginning").
3. When Subject is an axis, see each subject's **group** in a separate cell, similar to the cross-subject metrics view.
## Model: three interchangeable dimensions
Export data spans three dimensions: **Date**, **Subject**, **Parameter**. A 2-D CSV grid can place two of them on the axes; the third must be **pinned** to a single value (a cell holds exactly one value).
Each dimension yields an ordered list of **members**:
- **Date** → sorted unique `YYYY-MM-DD` keys present in the data.
- **Subject** → animals, headers disambiguated by `animal_id_string` on duplicate names (existing logic). When a group field is chosen, subjects are **clustered by group value, then sorted by name** within each group.
- **Parameter** → `listExportableParams(dailyTemplate, statuses)` (session metrics, then daily fields — unchanged).
Every cell is identified by a full `(dateKey, animalId, param)` triple: two coordinates come from the row/column members, the third from the pinned value. Lookup goes through a status index `Map(dateKey → Map(animalId → status))` (first-status-per-cell wins, as today), then `extractValue(status, param)`.
Because Subject is always exactly one of the three dimensions (row, column, or pinned), the group annotation attaches to **at most one axis** — there is no 2-D corner-block complexity.
## Modal UI (`ExportDataModal.jsx`)
Replaces today's single "Parameter" dropdown with four controls:
- **Rows** — Date / Subject / Parameter.
- **Columns** — the two dimensions not chosen for Rows (auto-excludes the row pick; if a change collides, the other axis auto-shifts to a still-valid dimension).
- **Fixed: `<leftover dim>`** — value dropdown for the auto-pinned third dimension. Its options are that dimension's members (a date, a subject, or a parameter, grouped like today for parameters).
- **Group by** — subject_info fields plus "— None —". **Shown only when Subject is a row or column axis.** Defaults to the experiment's saved group field (`localStorage['exp-subject-group-<id>']`); falls back to None when unset or the field no longer exists.
Live preview line stays: `N rows × M columns` (or "No data …" when empty). **Default state reproduces today's export exactly**: Rows = Date, Columns = Subject, Fixed = Parameter (first parameter). Export button disabled when the grid has no data rows.
## CSV output format
Structure: pinned-dimension context line, blank line, optional group row, header row, data rows.
- **Line 1 (context):** two cells — the pinned dimension's label and its value, e.g. `Parameter,Total attempts` or `Subject,Mouse-01`. Beginning only (no footer).
- **Blank line.**
- **Grid**, with the group cell riding on whichever axis holds Subject.
**Subject = columns** (e.g. Rows = Date, Fixed = Parameter) → extra **header row** labeled `Group`:
```
Parameter,Total attempts
Group,Control,Control,Drug,Drug
Date,Mouse-01,Mouse-02,Mouse-03,Mouse-04
2026-01-01,12,10,8,9
```
**Subject = rows** (e.g. Cols = Parameter, Fixed = Date) → extra **leading column** labeled `Group`:
```
Date,2026-01-01
Group,Subject,Total attempts,Success rate
Control,Mouse-01,12,0.83
Drug,Mouse-03,8,0.71
```
Rules:
- The grid **corner label** is the row dimension's name (`Date`, `Subject`, or `Parameter`) — no longer hardcoded `Date`.
- Missing group value → `—`. When **Group by = None**, the group row/column is omitted entirely.
- **Empty-member rule:** Subject members are **always kept** (matches today's "keep all subject columns"). Date and Parameter members are **dropped when entirely blank** in the current grid.
- Cell values use existing semantics: `0` / `''` are real; genuinely-missing lookups render as blank. CSV escaping via existing `escapeCsvCell`.
## Refactor of `dataExport.js`
Keep `extractValue` and `listExportableParams` unchanged. Replace single-purpose `buildMatrix` with dimension-agnostic pieces:
- `listDateMembers(statuses)` → sorted unique dateKeys.
- `listSubjectMembers(animals, groupField)``[{ id, header, group }]`, clustered by group then name; header disambiguation preserved.
- `buildStatusIndex(statuses)``Map(dateKey → Map(animalId → status))`, first-wins.
- `buildMatrix({ rowDim, colDim, pinnedDim, pinnedMember, groupField }, { statuses, animals, dailyTemplate })``{ context, groupAxis, corner, columns, rows }`. Resolves each cell's `(dateKey, animalId, param)` triple through the index + `extractValue`; applies the keep-subjects / drop-blank-date-and-param rule.
- `toCSV(matrix)` → emits context line, blank line, optional group row, header row, data rows, with the dynamic corner label.
- `csvFilename(title, pinnedMember.label)` → signature unchanged.
## Testing (`tests/dataExport.test.js`)
Existing `extractValue` / `listExportableParams` tests stay green. Add:
- `listDateMembers`, `listSubjectMembers` (including group clustering + name tie-break + header disambiguation), `buildStatusIndex` (first-wins).
- `buildMatrix` for all three axis pairings in both orientations.
- Group row (Subject = columns) vs. group column (Subject = rows) placement; `—` fallback; Group = None omits it.
- Empty-member rule: blank date/parameter members dropped, subjects retained.
- `toCSV` snapshot including the context line and a group row.
## Out of scope (YAGNI)
- Stacking multiple values of the pinned dimension into one file (chose "pick one value").
- Footer/repeat of the context line (beginning only).
- Experiment-level metadata block (pinned dimension only).
- Persisting axis choices across sessions (default reproduces current behavior; group field reuses existing saved value only).
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
['@babel/preset-react', { runtime: 'automatic' }],
],
};
+105
View File
@@ -0,0 +1,105 @@
// @ts-check
const { test, expect } = require('@playwright/test');
/**
* E2E tests run against the full Docker stack (frontend on port 52867,
* backend on port 3001). To run locally:
* docker-compose up -d
* npx playwright test
*/
const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:52867';
test.describe('UI Flow — create experiment → add animal → log status', () => {
test('full happy path', async ({ page }) => {
await page.goto(BASE_URL);
// ── Create experiment ────────────────────────────────────────
await page.click('button:has-text("New Experiment")');
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByLabel(/experiment title/i).fill('E2E Test Study');
await page.click('button:has-text("Create Experiment")');
// Modal closes and new experiment appears in the list
await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(page.getByText('E2E Test Study')).toBeVisible();
// ── Navigate into experiment ──────────────────────────────────
await page.click('text=E2E Test Study');
await expect(page.url()).toContain('/experiments/');
// ── Add animal ────────────────────────────────────────────────
await page.click('button:has-text("Add Animal")');
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByLabel(/animal id/i).fill('M-E2E-001');
await page.getByLabel(/animal name/i).fill('TestMouse');
await page.click('button:has-text("Add Animal")');
await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(page.getByText('TestMouse')).toBeVisible();
// ── Navigate into animal ──────────────────────────────────────
await page.click('text=TestMouse');
await expect(page.url()).toContain('/animals/');
// ── Log daily status ──────────────────────────────────────────
await page.click('button:has-text("Log Daily Status")');
await expect(page.getByRole('dialog')).toBeVisible();
// Date is pre-filled with today; add vitals
await page.getByLabel(/vitals/i).fill('HR 70, Temp 37.0');
await page.getByLabel(/treatment/i).fill('Control');
await page.click('button:has-text("Log Status")');
await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(page.getByText('HR 70, Temp 37.0')).toBeVisible();
});
});
test.describe('UX Flow — validation error states', () => {
test('empty experiment title shows error', async ({ page }) => {
await page.goto(BASE_URL);
await page.click('button:has-text("New Experiment")');
await page.click('button:has-text("Create Experiment")');
await expect(page.getByRole('alert')).toContainText(/title is required/i);
});
test('empty animal fields show errors', async ({ page }) => {
await page.goto(BASE_URL);
// Need an experiment to navigate into — create one first
await page.click('button:has-text("New Experiment")');
await page.getByLabel(/experiment title/i).fill('UX Test Study');
await page.click('button:has-text("Create Experiment")');
await page.click('text=UX Test Study');
await page.click('button:has-text("Add Animal")');
await page.click('button:has-text("Add Animal")'); // submit without filling
const alerts = page.getByRole('alert');
await expect(alerts).toHaveCount(2); // ID + Name errors
});
test('missing date on daily status shows error', async ({ page }) => {
await page.goto(BASE_URL);
// Create experiment + animal to get to daily status form
await page.click('button:has-text("New Experiment")');
await page.getByLabel(/experiment title/i).fill('Date Test Study');
await page.click('button:has-text("Create Experiment")');
await page.click('text=Date Test Study');
await page.click('button:has-text("Add Animal")');
await page.getByLabel(/animal id/i).fill('M-DT-001');
await page.getByLabel(/animal name/i).fill('DateTestMouse');
await page.click('button:has-text("Add Animal")');
await page.click('text=DateTestMouse');
await page.click('button:has-text("Log Daily Status")');
// Clear the date field
await page.getByLabel(/date/i).fill('');
await page.click('button:has-text("Log Status")');
await expect(page.getByRole('alert')).toContainText(/date is required/i);
});
});
+9133 -3579
View File
File diff suppressed because it is too large Load Diff
+28 -11
View File
@@ -6,9 +6,8 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test": "node --max-old-space-size=8192 node_modules/.bin/jest --runInBand --forceExit",
"test:coverage": "node --max-old-space-size=8192 node_modules/.bin/jest --runInBand --forceExit --coverage",
"test:e2e": "playwright test"
},
"dependencies": {
@@ -16,22 +15,40 @@
"date-fns": "^3.6.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.24.0"
"react-router-dom": "^6.24.0",
"recharts": "^2.15.4"
},
"devDependencies": {
"@babel/core": "^7.24.7",
"@babel/preset-env": "^7.24.7",
"@babel/preset-react": "^7.24.6",
"@playwright/test": "^1.44.1",
"@testing-library/jest-dom": "^6.4.6",
"@testing-library/react": "^16.0.0",
"@testing-library/react": "^14.3.1",
"@testing-library/user-event": "^14.5.2",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-v8": "^1.6.0",
"autoprefixer": "^10.4.19",
"jsdom": "^24.1.0",
"babel-jest": "^29.7.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.4",
"vite": "^4.5.3",
"vitest": "^1.6.0"
"vite": "^4.5.3"
},
"jest": {
"testEnvironment": "jsdom",
"setupFilesAfterEnv": [
"<rootDir>/tests/setup.js"
],
"testMatch": [
"**/tests/**/*.test.{js,jsx}"
],
"transform": {
"^.+\\.[jt]sx?$": "babel-jest"
},
"moduleNameMapper": {
"\\.(css|less|scss|sass)$": "identity-obj-proxy"
}
}
}
+14
View File
@@ -0,0 +1,14 @@
const { defineConfig, devices } = require('@playwright/test');
module.exports = defineConfig({
testDir: './e2e',
timeout: 30000,
retries: process.env.CI ? 2 : 0,
use: {
baseURL: process.env.E2E_BASE_URL || 'http://localhost:52867',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
});
+23 -3
View File
@@ -1,8 +1,11 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { Routes, Route, Link, useLocation } from 'react-router-dom';
import { systemApi } from './api/client';
import Dashboard from './pages/Dashboard';
import ExperimentDetail from './pages/ExperimentDetail';
import AnimalDetail from './pages/AnimalDetail';
import DailyStatusDetail from './pages/DailyStatusDetail';
import ExperimentDayView from './pages/ExperimentDayView';
function Navbar() {
return (
@@ -33,18 +36,35 @@ function NotFound() {
);
}
function Footer() {
const [dbTimezone, setDbTimezone] = useState(null);
useEffect(() => {
systemApi.info().then((d) => setDbTimezone(d.dbTimezone)).catch(() => {});
}, []);
return (
<footer className="border-t border-gray-200 mt-12 py-3">
<div className="max-w-5xl mx-auto px-4 text-xs text-gray-400 text-right">
Database timezone: <span className="font-mono">{dbTimezone ?? '…'}</span>
</div>
</footer>
);
}
export default function App() {
return (
<div className="min-h-screen bg-gray-50">
<div className="min-h-screen bg-gray-50 flex flex-col">
<Navbar />
<main>
<main className="flex-1">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/experiments/:id" element={<ExperimentDetail />} />
<Route path="/experiments/:id/day/:date" element={<ExperimentDayView />} />
<Route path="/animals/:id" element={<AnimalDetail />} />
<Route path="/daily-statuses/:id" element={<DailyStatusDetail />} />
<Route path="*" element={<NotFound />} />
</Routes>
</main>
<Footer />
</div>
);
}
+35
View File
@@ -29,6 +29,18 @@ export const experimentsApi = {
create: (data) => api.post('/experiments', data).then((r) => r.data),
update: (id, data) => api.put(`/experiments/${id}`, data).then((r) => r.data),
delete: (id) => api.delete(`/experiments/${id}`),
getTemplate: (id) => api.get(`/experiments/${id}/template`).then((r) => r.data),
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 ?? {});
return api.get(`/experiments/${id}/daily-statuses`, { params }).then((r) => r.data);
},
getDayStatuses: (id, date) => api.get(`/experiments/${id}/daily-statuses`, { params: { date } }).then((r) => r.data),
getCalendar: (id, field) => api.get(`/experiments/${id}/calendar`, { params: { field } }).then((r) => r.data),
};
// ── Animals ───────────────────────────────────────────────────────────────────
@@ -39,6 +51,8 @@ export const animalsApi = {
create: (data) => api.post('/animals', data).then((r) => r.data),
update: (id, data) => api.put(`/animals/${id}`, data).then((r) => r.data),
delete: (id) => api.delete(`/animals/${id}`),
updateCsvConfig: (id, config) =>
api.patch(`/animals/${id}/csv-config`, config).then((r) => r.data),
};
// ── Daily Statuses ─────────────────────────────────────────────────────────────
@@ -49,9 +63,30 @@ export const dailyStatusesApi = {
create: (data) => api.post('/daily-statuses', data).then((r) => r.data),
update: (id, data) => api.put(`/daily-statuses/${id}`, data).then((r) => r.data),
delete: (id) => api.delete(`/daily-statuses/${id}`),
updateAnalysisSummary: (id, data) => api.put(`/daily-statuses/${id}/analysis-summary`, data).then((r) => r.data),
};
// ── Audit Logs ────────────────────────────────────────────────────────────────
export const auditLogsApi = {
list: (params) => api.get('/audit-logs', { params }).then((r) => r.data),
};
// ── System ────────────────────────────────────────────────────────────────────
export const systemApi = {
info: () => api.get('/info').then((r) => r.data),
};
// ── Session Files ─────────────────────────────────────────────────────────────
export const sessionFilesApi = {
list: (statusId) => api.get(`/daily-statuses/${statusId}/files`).then((r) => r.data),
create: (statusId, files) => api.post(`/daily-statuses/${statusId}/files`, files).then((r) => r.data),
delete: (id) => api.delete(`/session-files/${id}`),
};
// ── Analyses ──────────────────────────────────────────────────────────────────
export const analysesApi = {
listForStatus: (statusId) => api.get(`/daily-statuses/${statusId}/analyses`).then((r) => r.data),
create: (statusId, data) => api.post(`/daily-statuses/${statusId}/analyses`, data).then((r) => r.data),
get: (id) => api.get(`/analyses/${id}`).then((r) => r.data),
delete: (id) => api.delete(`/analyses/${id}`),
};
+734
View File
@@ -0,0 +1,734 @@
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, serializePlotConfig, readPlotConfig } from '../lib/crossSubjectChart';
// ── Color palettes ─────────────────────────────────────────────────────────────
const CAT_COLORS = { Success: '#22c55e', Failure: '#ef4444', Other: '#9ca3af' };
const EXTRA_CAT = ['#f59e0b', '#a855f7', '#ec4899', '#14b8a6', '#f97316'];
const GROUP_COLORS = ['#6366f1', '#f59e0b', '#22c55e', '#ef4444', '#a855f7', '#14b8a6', '#ec4899', '#f97316', '#84cc16', '#06b6d4'];
function catColor(cat, idx = 0) {
return CAT_COLORS[cat] ?? EXTRA_CAT[idx % EXTRA_CAT.length];
}
// ── Shared tooltip ─────────────────────────────────────────────────────────────
function ChartTooltip({ active, payload, label, labelKey = 'dateStr', unit = '' }) {
if (!active || !payload?.length) return null;
const header = payload[0]?.payload?.[labelKey] ?? `Day ${label}`;
return (
<div className="bg-white border border-gray-200 rounded shadow-sm px-3 py-2 text-xs space-y-0.5">
<p className="font-semibold text-gray-700 mb-1">{header}</p>
{payload.map((p) => (
<p key={p.dataKey} style={{ color: p.stroke ?? p.color }}>
{p.name}: {p.value != null ? `${p.value}${unit}` : '—'}
</p>
))}
</div>
);
}
// ── Shared helpers ─────────────────────────────────────────────────────────────
const DAYS_REACH_RE = /#?\s*days?\s*reach/i;
const SELECT_CLS = 'border border-gray-200 rounded px-1.5 py-0.5 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400';
const INPUT_CLS = 'w-16 border border-gray-200 rounded px-1.5 py-0.5 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400';
const STEP_OPTIONS = [1, 2, 5, 10];
function defaultXField(fields) {
return fields.find((f) => DAYS_REACH_RE.test(f.label))?.fieldId ?? '__days__';
}
function numericFieldVal(status, field) {
if (!field) return null;
const raw = field.builtin ? status[field.key] : status.custom_fields?.[field.fieldId];
const n = parseFloat(raw);
return isNaN(n) ? null : n;
}
function buildTicks(data, step) {
const xs = data.map((d) => d.x).filter((x) => x != null && isFinite(x));
if (!xs.length) return undefined;
const lo = Math.floor(Math.min(...xs));
const hi = Math.ceil(Math.max(...xs));
const ticks = [];
for (let t = Math.ceil(lo / step) * step; t <= hi; t += step) ticks.push(t);
if (!ticks.length || ticks[0] > lo) ticks.unshift(lo);
if (ticks[ticks.length - 1] < hi) ticks.push(hi);
return ticks;
}
function TickStepControl({ value, onChange }) {
return (
<div className="flex items-center gap-1.5 text-xs">
<span className="text-gray-400">Tick every</span>
<div className="flex rounded border border-gray-200 overflow-hidden">
{STEP_OPTIONS.map((s) => (
<button key={s} type="button" onClick={() => onChange(s)}
className={`px-2 py-0.5 transition-colors border-l border-gray-200 first:border-l-0
${value === s ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
{s}
</button>
))}
</div>
</div>
);
}
// ── Per-chart customisation hook & UI ──────────────────────────────────────────
const HEIGHT_OPTIONS = [
{ label: 'S', value: 140 },
{ label: 'M', value: 220 },
{ label: 'L', value: 340 },
];
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,
};
}
function ChartHeader({ title, isDirty, expanded, onToggle }) {
return (
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-gray-500 font-medium">{title}</p>
<button type="button" onClick={onToggle}
className={[
'text-xs px-2 py-0.5 rounded border transition-colors',
expanded
? 'bg-gray-100 border-gray-300 text-gray-700'
: isDirty
? 'bg-indigo-50 border-indigo-200 text-indigo-600'
: 'bg-white border-gray-200 text-gray-400 hover:text-gray-700 hover:border-gray-300',
].join(' ')}
>
{expanded ? '▲ collapse' : '▾ customize'}
</button>
</div>
);
}
// Shown only when the chart card is expanded — renders per-chart controls.
function ChartSettingsBar({ s, xAxisFields, globalXField, extra }) {
return (
<div className="mb-3 p-3 bg-gray-50 border border-gray-100 rounded-lg flex flex-wrap gap-x-5 gap-y-2 items-center text-xs">
{/* Size */}
<div className="flex items-center gap-1.5">
<span className="text-gray-400 shrink-0">Size:</span>
<div className="flex rounded border border-gray-200 overflow-hidden">
{HEIGHT_OPTIONS.map((opt) => (
<button key={opt.value} type="button" onClick={() => s.setHeight(opt.value)}
className={`px-2 py-0.5 transition-colors border-l border-gray-200 first:border-l-0
${s.height === opt.value ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
{opt.label}
</button>
))}
</div>
</div>
{/* Y range */}
<div className="flex items-center gap-1.5">
<span className="text-gray-400 shrink-0">Y range:</span>
<input type="number" placeholder="min" value={s.yMin} onChange={(e) => s.setYMin(e.target.value)} className={INPUT_CLS} />
<span className="text-gray-300"></span>
<input type="number" placeholder="max" value={s.yMax} onChange={(e) => s.setYMax(e.target.value)} className={INPUT_CLS} />
</div>
{/* X zoom */}
<div className="flex items-center gap-1.5">
<span className="text-gray-400 shrink-0">X zoom:</span>
<input type="number" placeholder="min" value={s.xZoomMin} onChange={(e) => s.setXZoomMin(e.target.value)} className={INPUT_CLS} />
<span className="text-gray-300"></span>
<input type="number" placeholder="max" value={s.xZoomMax} onChange={(e) => s.setXZoomMax(e.target.value)} className={INPUT_CLS} />
</div>
{/* Per-chart X axis field override */}
{xAxisFields.length > 0 && (
<div className="flex items-center gap-1.5">
<span className="text-gray-400 shrink-0">X data:</span>
<select
value={s.xFieldOverride ?? globalXField}
onChange={(e) => {
const v = e.target.value;
s.setXFieldOverride(v === globalXField ? null : v);
}}
className={SELECT_CLS}
>
<option value="__days__"># Days Reach (ordinal)</option>
{xAxisFields.map((f) => <option key={f.fieldId} value={f.fieldId}>{f.label}</option>)}
</select>
</div>
)}
{/* Slot for chart-specific controls (e.g. Y line visibility) */}
{extra}
{s.isDirty && (
<button type="button" onClick={s.reset}
className="text-xs text-red-400 hover:text-red-600 transition-colors ml-auto">
Reset
</button>
)}
</div>
);
}
// ── Pure data-computation helpers ──────────────────────────────────────────────
function computeSubjectChartData(xField, statuses, xAxisFields) {
const xAxisField = xAxisFields.find((f) => f.fieldId === xField);
const xLabel = xAxisField ? xAxisField.label : '# Days Reach';
const withMetrics = statuses
.filter((s) => s.analysis_summary?.total != null)
.sort((a, b) => String(a.date).localeCompare(String(b.date)));
const rows = withMetrics.map((s, i) => {
const xVal = xField === '__days__' || !xAxisField ? i + 1 : numericFieldVal(s, xAxisField);
const dateStr = format(new Date(String(s.date).slice(0, 10) + 'T12:00:00'), 'MMM d');
const { counts = {}, total, success_rate } = s.analysis_summary;
return { _x: xVal, dateStr, total, rate: success_rate != null ? +(success_rate * 100).toFixed(1) : null, ...counts };
}).filter((r) => r._x != null).sort((a, b) => a._x - b._x);
const data = rows.map((r) => ({ ...r, x: r._x }));
const catSet = new Set();
for (const d of data) {
for (const k of Object.keys(d)) {
if (!['x', '_x', 'dateStr', 'total', 'rate'].includes(k)) catSet.add(k);
}
}
return { data, cats: [...catSet], xLabel };
}
function computeExpChartData(xField, chartType, metric, animalRows, lines, xAxisFields) {
// chartType: 'count' | 'rate'
const xAxisField = xAxisFields.find((f) => f.fieldId === xField);
const xLabel = xAxisField ? xAxisField.label : '# Days Reach';
function getX(row, dayIndex) {
if (xField === '__days__') return dayIndex + 1;
return numericFieldVal(row, xAxisField);
}
if (xField === '__days__') {
const maxDay = Math.max(0, ...lines.map((l) => animalRows[l.id].length));
if (maxDay === 0) return { data: [], xLabel };
const data = Array.from({ length: maxDay }, (_, i) => ({ x: i + 1 }));
for (const line of lines) {
animalRows[line.id].forEach((row, i) => {
const s = row.analysis_summary;
if (chartType === 'count') {
data[i][line.id] = metric === 'total' ? s.total : (s.counts?.[metric] ?? null);
} else {
data[i][line.id] = s.success_rate != null ? +(s.success_rate * 100).toFixed(1) : null;
}
});
}
return { data, xLabel };
}
// Field-based x axis
const allPts = [];
for (const line of lines) {
animalRows[line.id].forEach((row, i) => {
const x = getX(row, i);
if (x !== null) allPts.push({ x, animalId: line.id, summary: row.analysis_summary });
});
}
const sortedX = [...new Set(allPts.map((p) => p.x))].sort((a, b) => a - b);
if (sortedX.length === 0) return { data: [], xLabel };
const data = sortedX.map((x) => {
const pt = { x };
for (const p of allPts.filter((pp) => pp.x === x)) {
const s = p.summary;
if (chartType === 'count') {
pt[p.animalId] = metric === 'total' ? s.total : (s.counts?.[metric] ?? null);
} else {
pt[p.animalId] = s.success_rate != null ? +(s.success_rate * 100).toFixed(1) : null;
}
}
return pt;
});
return { data, xLabel };
}
// ── Subject charts ─────────────────────────────────────────────────────────────
export function SubjectAnalysisCharts({ statuses, dailyTemplate }) {
const xAxisFields = useMemo(
() => (dailyTemplate ?? []).filter((f) => f.active),
[dailyTemplate],
);
const [xField, setXField] = useState(() => defaultXField(xAxisFields));
const [tickStep, setTickStep] = useState(1);
const countsS = useChartSettings(200);
const rateS = useChartSettings(160);
// Shared base computation — used by both charts when no override is set
const sharedData = useMemo(
() => computeSubjectChartData(xField, statuses, xAxisFields),
[xField, statuses, xAxisFields],
);
// Per-chart overrides: only recompute when the override differs from the shared field
const countsData = useMemo(() => {
const ef = countsS.xFieldOverride;
if (!ef || ef === xField) return sharedData;
return computeSubjectChartData(ef, statuses, xAxisFields);
}, [countsS.xFieldOverride, xField, sharedData, statuses, xAxisFields]);
const rateData = useMemo(() => {
const ef = rateS.xFieldOverride;
if (!ef || ef === xField) return sharedData;
return computeSubjectChartData(ef, statuses, xAxisFields);
}, [rateS.xFieldOverride, xField, sharedData, statuses, xAxisFields]);
const countsTicks = useMemo(() => buildTicks(countsData.data, tickStep), [countsData.data, tickStep]);
const rateTicks = useMemo(() => buildTicks(rateData.data, tickStep), [rateData.data, tickStep]);
// Y-line visibility for the counts chart (null = show all)
const [visibleCats, setVisibleCats] = useState(null);
useEffect(() => { setVisibleCats(null); }, [countsData.cats.join(',')]); // reset when cats change
if (sharedData.data.length === 0) return null;
function toggleCat(cat) {
setVisibleCats((prev) => {
if (prev === null) return new Set([cat]);
const next = new Set(prev);
next.has(cat) ? next.delete(cat) : next.add(cat);
return next.size === 0 ? null : next;
});
}
function xAxisProps(data, ticks, s) {
const ef = s.xFieldOverride ?? xField;
const xAxisField = xAxisFields.find((f) => f.fieldId === ef);
const xLabel = xAxisField ? xAxisField.label : '# Days Reach';
const domain = s.xDomain ?? (ticks?.length ? [ticks[0], ticks[ticks.length - 1]] : ['auto', 'auto']);
return { dataKey: 'x', type: 'number', tick: { fontSize: 10 }, ticks, domain, allowDecimals: false,
label: { value: xLabel, position: 'insideBottomRight', offset: -4, fontSize: 10 } };
}
return (
<div className="bg-white border border-gray-200 rounded-xl p-5 mb-6 space-y-5">
{/* Global controls */}
<div className="flex items-center flex-wrap gap-3">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">Session metrics over time</h2>
<div className="flex items-center gap-1.5 text-xs">
<span className="text-gray-400">X axis:</span>
<select value={xField} onChange={(e) => setXField(e.target.value)} className={SELECT_CLS}>
<option value="__days__"># Days Reach (ordinal)</option>
{xAxisFields.map((f) => <option key={f.fieldId} value={f.fieldId}>{f.label}</option>)}
</select>
</div>
<TickStepControl value={tickStep} onChange={setTickStep} />
</div>
{/* Chart 1 — counts */}
<div>
<ChartHeader title="Attempts per session" isDirty={countsS.isDirty} expanded={countsS.expanded} onToggle={() => countsS.setExpanded((e) => !e)} />
{countsS.expanded && (
<ChartSettingsBar s={countsS} xAxisFields={xAxisFields} globalXField={xField}
extra={
countsData.cats.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-gray-400 shrink-0">Y lines:</span>
{countsData.cats.map((cat, i) => {
const on = visibleCats === null || visibleCats.has(cat);
return (
<button key={cat} type="button" onClick={() => toggleCat(cat)}
style={{ color: catColor(cat, i), borderColor: on ? catColor(cat, i) : '#e5e7eb' }}
className={`px-2 py-0.5 rounded border text-xs transition-opacity ${on ? 'opacity-100' : 'opacity-35'}`}>
{cat}
</button>
);
})}
</div>
)
}
/>
)}
<ResponsiveContainer width="100%" height={countsS.height}>
<LineChart data={countsData.data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
<XAxis {...xAxisProps(countsData.data, countsTicks, countsS)} />
<YAxis tick={{ fontSize: 10 }} width={30} allowDecimals={false} domain={countsS.yDomain} />
<Tooltip content={<ChartTooltip labelKey="dateStr" />} />
<Legend wrapperStyle={{ fontSize: 11 }} />
{countsData.cats
.filter((cat) => visibleCats === null || visibleCats.has(cat))
.map((cat, i) => (
<Line key={cat} type="monotone" dataKey={cat} stroke={catColor(cat, i)} strokeWidth={2}
dot={{ r: 3 }} activeDot={{ r: 4 }} connectNulls isAnimationActive={false} />
))}
<Line type="monotone" dataKey="total" name="Total" stroke="#3b82f6" strokeWidth={1.5}
strokeDasharray="5 3" dot={{ r: 3 }} connectNulls isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</div>
{/* Chart 2 — success rate */}
<div>
<ChartHeader title="Success rate (%)" isDirty={rateS.isDirty} expanded={rateS.expanded} onToggle={() => rateS.setExpanded((e) => !e)} />
{rateS.expanded && (
<ChartSettingsBar s={rateS} xAxisFields={xAxisFields} globalXField={xField} />
)}
<ResponsiveContainer width="100%" height={rateS.height}>
<LineChart data={rateData.data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
<XAxis {...xAxisProps(rateData.data, rateTicks, rateS)} />
<YAxis tick={{ fontSize: 10 }} width={36}
domain={rateS.yDomain[0] !== 'auto' || rateS.yDomain[1] !== 'auto' ? rateS.yDomain : [0, 100]}
tickFormatter={(v) => `${v}%`} />
<Tooltip content={<ChartTooltip unit="%" labelKey="dateStr" />} />
<Line type="monotone" dataKey="rate" name="Success rate" stroke="#6366f1" strokeWidth={2}
dot={{ r: 3 }} connectNulls isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
}
// ── Experiment charts ──────────────────────────────────────────────────────────
const METRIC_OPTIONS = [
{ value: 'total', label: 'Total attempts' },
{ value: 'Success', label: 'Success count' },
{ value: 'Failure', label: 'Failure count' },
];
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 ?? '';
// 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(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, 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(() => {
const animalGroup = {};
for (const animal of animals) {
const raw = groupBy ? animal.subject_info?.[groupBy] : null;
animalGroup[animal.id] = raw != null && raw !== '' ? String(raw) : '—';
}
const groups = [...new Set(Object.values(animalGroup))].sort();
const statusesByAnimal = {};
for (const s of experimentStatuses) {
if (!statusesByAnimal[s.animal_id]) statusesByAnimal[s.animal_id] = [];
statusesByAnimal[s.animal_id].push(s);
}
const animalRows = {};
for (const animal of animals) {
animalRows[animal.id] = (statusesByAnimal[animal.id] ?? [])
.filter((s) => s.analysis_summary?.total != null)
.sort((a, b) => String(a.date).localeCompare(String(b.date)));
}
const lines = animals
.filter((a) => animalRows[a.id]?.length > 0)
.map((a) => ({
id: a.id,
name: a.animal_name,
group: animalGroup[a.id],
color: GROUP_COLORS[groups.indexOf(animalGroup[a.id]) % GROUP_COLORS.length],
}));
return { animalRows, lines };
}, [animals, experimentStatuses, groupBy]);
// Step 2: count chart data — depends on xField for counts + metric
const { data: countData, xLabel: countsXLabel } = useMemo(() => {
if (lines.length === 0) return { data: [], xLabel: '' };
const ef = countsS.xFieldOverride ?? xField;
return computeExpChartData(ef, 'count', metric, animalRows, lines, xAxisFields);
}, [animalRows, lines, countsS.xFieldOverride, xField, metric, xAxisFields]);
// Step 3: rate chart data — depends on xField for rate
const { data: rateData, xLabel: rateXLabel } = useMemo(() => {
if (lines.length === 0) return { data: [], xLabel: '' };
const ef = rateS.xFieldOverride ?? xField;
return computeExpChartData(ef, 'rate', null, animalRows, lines, xAxisFields);
}, [animalRows, lines, rateS.xFieldOverride, xField, xAxisFields]);
const countsTicks = useMemo(() => buildTicks(countData, tickStep), [countData, tickStep]);
const rateTicks = useMemo(() => buildTicks(rateData, tickStep), [rateData, tickStep]);
const hasData = lines.length > 0;
function xAxisProps(data, ticks, s, fallbackLabel) {
const domain = s.xDomain ?? (ticks?.length ? [ticks[0], ticks[ticks.length - 1]] : ['auto', 'auto']);
const label = (s.xFieldOverride ? xAxisFields.find((f) => f.fieldId === s.xFieldOverride)?.label : null) ?? fallbackLabel;
return { dataKey: 'x', type: 'number', tick: { fontSize: 10 }, ticks, domain, allowDecimals: false,
label: { value: label, position: 'insideBottomRight', offset: -4, fontSize: 10 } };
}
function ExpTooltip({ active, payload, label, unit = '' }) {
if (!active || !payload?.length) return null;
const rows = sortPayloadByValueDesc(payload);
return (
<div className="bg-white border border-gray-200 rounded shadow-sm px-3 py-2 text-xs space-y-0.5 max-h-48 overflow-y-auto">
<p className="font-semibold text-gray-700 mb-1">{label}</p>
{rows.map((p) => {
const line = lines.find((l) => l.id === p.dataKey);
return (
<p key={p.dataKey} style={{ color: p.stroke }}>
{line?.name ?? p.dataKey}{line?.group && line.group !== '—' ? ` (${line.group})` : ''}: {p.value}{unit}
</p>
);
})}
</div>
);
}
return (
<div className="bg-white border border-gray-200 rounded-xl p-5 mb-6 space-y-5">
{/* Global controls */}
<div className="flex items-center flex-wrap gap-3">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">Cross-subject metrics</h2>
<div className="flex items-center gap-1.5 text-xs">
<span className="text-gray-400">X axis:</span>
<select value={xField} onChange={(e) => setXField(e.target.value)} className={SELECT_CLS}>
<option value="__days__"># Days Reach (ordinal)</option>
{xAxisFields.map((f) => <option key={f.fieldId} value={f.fieldId}>{f.label}</option>)}
</select>
</div>
{groupableFields.length > 0 && (
<div className="flex items-center gap-1.5 text-xs">
<span className="text-gray-400">Color by:</span>
<select value={groupBy} onChange={(e) => setGroupBy(e.target.value)} className={SELECT_CLS}>
<option value=""> none </option>
{groupableFields.map((f) => <option key={f.fieldId} value={f.fieldId}>{f.label}</option>)}
</select>
</div>
)}
<TickStepControl value={tickStep} onChange={setTickStep} />
{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>
)}
</div>
{!hasData && (
<p className="text-sm text-gray-400 italic">No sessions with saved metrics yet.</p>
)}
{hasData && (
<div className="flex gap-3 items-start">
<div className="flex-1 min-w-0 space-y-5">
{/* Chart 1 — counts */}
<div>
<div className="flex items-center justify-between mb-2 flex-wrap gap-2">
<div className="flex items-center gap-3">
<p className="text-xs text-gray-500 font-medium">Count per session</p>
{/* Y-metric selector stays at chart level — primary control */}
<div className="flex rounded border border-gray-200 overflow-hidden text-[10px]">
{METRIC_OPTIONS.map((opt) => (
<button key={opt.value} type="button" onClick={() => setMetric(opt.value)}
className={`px-2 py-0.5 transition-colors border-l border-gray-200 first:border-l-0
${metric === opt.value ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
{opt.label}
</button>
))}
</div>
</div>
<button type="button" onClick={() => countsS.setExpanded((e) => !e)}
className={[
'text-xs px-2 py-0.5 rounded border transition-colors',
countsS.expanded
? 'bg-gray-100 border-gray-300 text-gray-700'
: countsS.isDirty
? 'bg-indigo-50 border-indigo-200 text-indigo-600'
: 'bg-white border-gray-200 text-gray-400 hover:text-gray-700 hover:border-gray-300',
].join(' ')}>
{countsS.expanded ? ' collapse' : ' customize'}
</button>
</div>
{countsS.expanded && (
<ChartSettingsBar s={countsS} xAxisFields={xAxisFields} globalXField={xField} />
)}
<ResponsiveContainer width="100%" height={countsS.height}>
<LineChart data={countData} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
<XAxis {...xAxisProps(countData, countsTicks, countsS, countsXLabel)} />
<YAxis tick={{ fontSize: 10 }} width={30} allowDecimals={false} domain={countsS.yDomain} />
<Tooltip content={<ExpTooltip />} />
<Legend wrapperStyle={{ fontSize: 11 }}
formatter={(value) => lines.find((l) => l.id === value)?.name ?? value}
onClick={(o) => toggleHidden(o.dataKey)}
onMouseEnter={(o) => setHighlightedSubject(o.dataKey)}
onMouseLeave={() => setHighlightedSubject(null)} />
{lines.map((l) => (
<Line key={l.id} type="monotone" dataKey={l.id} name={l.id}
stroke={l.color}
strokeWidth={highlightedSubject === l.id ? 3.5 : 2}
hide={hiddenSubjects.has(l.id)}
dot={{ r: 3 }} activeDot={{ r: 5 }}
connectNulls isAnimationActive={false} />
))}
</LineChart>
</ResponsiveContainer>
</div>
{/* Chart 2 — success rate */}
<div>
<ChartHeader title="Success rate (%) — each line is one subject" isDirty={rateS.isDirty} expanded={rateS.expanded} onToggle={() => rateS.setExpanded((e) => !e)} />
{rateS.expanded && (
<ChartSettingsBar s={rateS} xAxisFields={xAxisFields} globalXField={xField} />
)}
<ResponsiveContainer width="100%" height={rateS.height}>
<LineChart data={rateData} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
<XAxis {...xAxisProps(rateData, rateTicks, rateS, rateXLabel)} />
<YAxis tick={{ fontSize: 10 }} width={36}
domain={rateS.yDomain[0] !== 'auto' || rateS.yDomain[1] !== 'auto' ? rateS.yDomain : [0, 100]}
tickFormatter={(v) => `${v}%`} />
<Tooltip content={<ExpTooltip unit="%" />} />
<Legend wrapperStyle={{ fontSize: 11 }}
formatter={(value) => lines.find((l) => l.id === value)?.name ?? value}
onClick={(o) => toggleHidden(o.dataKey)}
onMouseEnter={(o) => setHighlightedSubject(o.dataKey)}
onMouseLeave={() => setHighlightedSubject(null)} />
{lines.map((l) => (
<Line key={l.id} type="monotone" dataKey={l.id} name={l.id}
stroke={l.color}
strokeWidth={highlightedSubject === l.id ? 3.5 : 2}
hide={hiddenSubjects.has(l.id)}
dot={{ r: 3 }} activeDot={{ r: 5 }}
connectNulls isAnimationActive={false} />
))}
</LineChart>
</ResponsiveContainer>
</div>
</div>
{sidebarOpen && (
<SubjectSidebar
lines={lines}
hiddenSubjects={hiddenSubjects}
onToggleSubject={toggleHidden}
onToggleGroup={toggleGroup}
onShowAll={showAll}
onHighlight={setHighlightedSubject}
/>
)}
</div>
)}
</div>
);
}
+10 -4
View File
@@ -36,7 +36,7 @@ const ACTION_BADGE = {
DELETE: 'bg-red-100 text-red-800',
};
export default function AuditLogSection({ tableName, recordId }) {
export default function AuditLogSection({ tableName, recordId, limit = 50 }) {
const [logs, setLogs] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
@@ -46,14 +46,14 @@ export default function AuditLogSection({ tableName, recordId }) {
useEffect(() => {
setLoading(true);
auditLogsApi
.list({ tableName, recordId, limit: 50 })
.list({ tableName, recordId, limit })
.then(({ logs, total }) => {
setLogs(logs);
setTotal(total);
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [tableName, recordId]);
}, [tableName, recordId, limit]);
return (
<section aria-label="Modification History" className="mt-8">
@@ -76,7 +76,7 @@ export default function AuditLogSection({ tableName, recordId }) {
{logs.length > 0 && (
<div className="border border-gray-200 rounded-lg overflow-hidden divide-y divide-gray-100">
{logs.map((log) => (
{logs.slice(0, limit).map((log) => (
<div key={log.id} className="bg-white">
<button
className="w-full text-left px-4 py-3 flex items-center gap-3 hover:bg-gray-50 transition-colors"
@@ -108,6 +108,12 @@ export default function AuditLogSection({ tableName, recordId }) {
))}
</div>
)}
{total > limit && (
<p className="mt-2 text-xs text-gray-400">
Showing {limit} of {total} entries open the daily status page to see the full history.
</p>
)}
</section>
);
}
+744
View File
@@ -0,0 +1,744 @@
import React, { useState, useRef, useCallback } from 'react';
import { parseCSVHeaders, parseCSV, getUniqueNoteValues, runAnalysis, checkFrameConsecutive, computeNumericSeries } from '../lib/csvAnalysis';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts';
import { analysesApi, dailyStatusesApi, animalsApi } from '../api/client';
import Button from './ui/Button';
import RunSequenceView from './RunSequenceView';
// ── Constants ──────────────────────────────────────────────────────────────────
const DEFAULT_BUCKETS = [
{ name: 'Success', type: 'note', notes: [] },
{ name: 'Failure', type: 'note', notes: [] },
{ name: 'Other', type: 'note', notes: [] },
];
const DEFAULT_BUCKET_NAMES = new Set(DEFAULT_BUCKETS.map((b) => b.name));
const CATEGORY_COLORS = {
Success: { bg: 'bg-green-100', border: 'border-green-300', text: 'text-green-800', dot: 'bg-green-500' },
Failure: { bg: 'bg-red-100', border: 'border-red-300', text: 'text-red-800', dot: 'bg-red-500' },
Other: { bg: 'bg-gray-100', border: 'border-gray-300', text: 'text-gray-700', dot: 'bg-gray-400' },
};
const EXTRA_COLORS = [
{ bg: 'bg-indigo-100', border: 'border-indigo-300', text: 'text-indigo-800', dot: 'bg-indigo-500' },
{ bg: 'bg-yellow-100', border: 'border-yellow-300', text: 'text-yellow-800', dot: 'bg-yellow-500' },
{ bg: 'bg-purple-100', border: 'border-purple-300', text: 'text-purple-800', dot: 'bg-purple-500' },
{ bg: 'bg-pink-100', border: 'border-pink-300', text: 'text-pink-800', dot: 'bg-pink-500' },
{ bg: 'bg-teal-100', border: 'border-teal-300', text: 'text-teal-800', dot: 'bg-teal-500' },
];
function getCategoryColors(cat, customCategories) {
if (CATEGORY_COLORS[cat]) return CATEGORY_COLORS[cat];
const idx = customCategories.indexOf(cat) % EXTRA_COLORS.length;
return EXTRA_COLORS[Math.max(0, idx)];
}
// ── Sub-components ─────────────────────────────────────────────────────────────
function NoteChip({ value, category, colors, selected, onSelect, draggable, onDragStart }) {
return (
<span
draggable={draggable}
onDragStart={onDragStart}
onClick={onSelect}
title={category ? `Assigned to: ${category}` : 'Unassigned — click or drag to assign'}
className={`
inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-mono font-semibold
border cursor-pointer select-none transition-all
${colors ? `${colors.bg} ${colors.border} ${colors.text}` : 'bg-white border-gray-300 text-gray-700 hover:border-indigo-400'}
${selected ? 'ring-2 ring-indigo-500 ring-offset-1' : ''}
`}
>
{value}
{category && (
<span className={`w-1.5 h-1.5 rounded-full ${colors?.dot}`} />
)}
</span>
);
}
function CategoryBucket({
name, type, notes, colors, isOver, isCustom, unassignedCount,
onDrop, onDragOver, onDragLeave, onRemoveNote, onClickAssign,
onBulkAddUnassigned, onRemoveBucket,
}) {
return (
<div
onDragOver={(e) => { e.preventDefault(); onDragOver(); }}
onDragLeave={onDragLeave}
onDrop={(e) => { e.preventDefault(); onDrop(); }}
onClick={onClickAssign}
className={`
relative group rounded-lg border-2 border-dashed p-3 min-h-[80px] transition-all cursor-pointer
${isOver ? `${colors.border} ${colors.bg} scale-[1.02]` : 'border-gray-200 hover:border-gray-300'}
`}
>
<div className="flex items-center justify-between mb-2">
<p className={`text-xs font-semibold uppercase tracking-wide ${colors.text}`}>
{name}
{type === 'numerical' && (
<span className="ml-2 text-[9px] font-bold uppercase bg-white/60 px-1 py-0.5 rounded border border-current/20">
numerical
</span>
)}
</p>
{isCustom && (
<button type="button"
onClick={(e) => { e.stopPropagation(); onRemoveBucket(); }}
className="text-[10px] text-gray-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
title="Remove this field"></button>
)}
</div>
{type === 'numerical' && unassignedCount > 0 && (
<button type="button"
onClick={(e) => { e.stopPropagation(); onBulkAddUnassigned(); }}
className={`text-[10px] mb-2 px-2 py-0.5 rounded border ${colors.border} ${colors.text} hover:bg-white transition-colors`}>
+ Add all {unassignedCount} unassigned
</button>
)}
<div className="flex flex-wrap gap-1.5 min-h-[28px]">
{notes.length === 0 && (
<span className="text-xs text-gray-400 italic">Drop here</span>
)}
{notes.map((note) => (
<span key={note}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-mono font-semibold ${colors.bg} ${colors.border} ${colors.text} border`}
>
{note}
<button type="button" onClick={(e) => { e.stopPropagation(); onRemoveNote(note); }}
className="opacity-50 hover:opacity-100 leading-none"></button>
</span>
))}
</div>
</div>
);
}
// ── Main component ─────────────────────────────────────────────────────────────
export default function CsvAnalysis({ dailyStatusId, animal, onSaved, onSummaryPushed }) {
// Phase: 'upload' | 'columns' | 'classify' | 'results'
const [phase, setPhase] = useState('upload');
// Upload phase
const [isDragOver, setIsDragOver] = useState(false);
const [fileName, setFileName] = useState('');
const [rawText, setRawText] = useState('');
const [headers, setHeaders] = useState([]);
const fileInputRef = useRef(null);
// Columns phase
const [timestampCol, setTimestampCol] = useState('');
const [noteCol, setNoteCol] = useState('');
const [frameCol, setFrameCol] = useState('');
const [frameCheck, setFrameCheck] = useState(null);
const [parsePending, setParsePending] = useState(false);
// Classify phase
const [rows, setRows] = useState([]);
const [uniqueNotes, setUniqueNotes] = useState([]);
const [buckets, setBuckets] = useState(() => animal?.csv_analysis_config?.buckets ?? DEFAULT_BUCKETS);
const [selectedNote, setSelectedNote] = useState(null);
const [draggedNote, setDraggedNote] = useState(null);
const [overBucket, setOverBucket] = useState(null);
const [newCatName, setNewCatName] = useState('');
const [newCatType, setNewCatType] = useState('note');
const [showNewCat, setShowNewCat] = useState(false);
// Derived from buckets (recomputed every render — cheap)
const categories = buckets.map((b) => b.name);
const customCategories = buckets.filter((b) => !DEFAULT_BUCKET_NAMES.has(b.name)).map((b) => b.name);
const noteBuckets = buckets.filter((b) => b.type === 'note');
const numericalBuckets = buckets.filter((b) => b.type === 'numerical');
const classifications = Object.fromEntries(
noteBuckets.flatMap((b) => b.notes.map((n) => [n, b.name]))
);
// Results phase
const [results, setResults] = useState(null);
const [savedAnalysisId, setSavedAnalysisId] = useState(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState(null);
const [summaryPushing, setSummaryPushing] = useState(false);
const [summaryPushed, setSummaryPushed] = useState(false);
const [summaryError, setSummaryError] = useState(null);
const [configSaveError, setConfigSaveError] = useState(null);
// ── File reading ─────────────────────────────────────────────────────────────
function readFile(file) {
if (!file || !file.name.endsWith('.csv')) {
alert('Please upload a .csv file.');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target.result;
const hdrs = parseCSVHeaders(text);
setFileName(file.name);
setRawText(text);
setHeaders(hdrs);
// Default columns
const tsDefault = hdrs.find(h => /timestamp|time/i.test(h)) ?? hdrs[0] ?? '';
const noteDefault = hdrs.find(h => /note|label|event/i.test(h)) ?? hdrs[hdrs.length - 1] ?? '';
const frameDefault = hdrs.find(h => /frame/i.test(h)) ?? '';
setTimestampCol(tsDefault);
setNoteCol(noteDefault);
setFrameCol(frameDefault);
setFrameCheck(null);
setPhase('columns');
};
reader.readAsText(file);
}
const handleDrop = useCallback((e) => {
e.preventDefault();
setIsDragOver(false);
readFile(e.dataTransfer.files[0]);
}, []);
// ── Column selection → parse data ────────────────────────────────────────────
function loadData() {
setParsePending(true);
setTimeout(() => {
const { rows: parsed } = parseCSV(rawText);
const uniq = getUniqueNoteValues(parsed, noteCol);
setRows(parsed);
setUniqueNotes(uniq);
setBuckets((prev) => prev.map((b) => ({ ...b, notes: [] })));
setSelectedNote(null);
if (frameCol) setFrameCheck(checkFrameConsecutive(parsed, frameCol));
setParsePending(false);
setPhase('classify');
}, 20);
}
// ── Classification helpers ───────────────────────────────────────────────────
function assign(note, category) {
setBuckets((prev) => prev.map((b) => {
if (b.name === category) {
return b.notes.includes(note) ? b : { ...b, notes: [...b.notes, note] };
}
// Remove from any other bucket so a note belongs to at most one
return b.notes.includes(note) ? { ...b, notes: b.notes.filter((n) => n !== note) } : b;
}));
setSelectedNote(null);
}
function unassign(note) {
setBuckets((prev) => prev.map((b) =>
b.notes.includes(note) ? { ...b, notes: b.notes.filter((n) => n !== note) } : b
));
}
function addBucket(name, type) {
const trimmed = name.trim();
if (!trimmed || buckets.some((b) => b.name === trimmed)) return;
setBuckets((prev) => [...prev, { name: trimmed, type, notes: [] }]);
}
const unassignedNotes = uniqueNotes.filter(n => !classifications[n]);
const allAssigned = uniqueNotes.length > 0 && unassignedNotes.length === 0;
// ── Run analysis ─────────────────────────────────────────────────────────────
async function compute() {
// Note-only classifications so numerical buckets don't pollute counts/runs
const noteClassifications = Object.fromEntries(
noteBuckets.flatMap((b) => b.notes.map((n) => [n, b.name]))
);
const res = runAnalysis(rows, timestampCol, noteCol, noteClassifications);
// Numerical series — one per numerical bucket. xCol = frame if set, else timestamp.
const xCol = frameCol || timestampCol;
const numericSeries = numericalBuckets.map((b) => ({
bucket: b,
...computeNumericSeries(rows, xCol, noteCol, b),
}));
setResults({ ...res, numericSeries });
setPhase('results');
// Persist bucket config to the animal so future uploads pre-classify.
// Independent of dailyStatusId — runs even for ad-hoc analyses.
if (animal?.id) {
setConfigSaveError(null);
animalsApi.updateCsvConfig(animal.id, { buckets })
.catch((err) => setConfigSaveError(err.message ?? 'Failed to save bucket config'));
}
if (!dailyStatusId) return;
setSaving(true);
setSaveError(null);
try {
const saved = await analysesApi.create(dailyStatusId, {
file_name: fileName || null,
timestamp_col: timestampCol,
note_col: noteCol,
classifications: noteClassifications,
total_note_rows: res.totalNoteRows,
session_end_ts: res.sessionEndTs,
sequence: res.sequence,
category_counts: res.categoryCounts,
consecutive_stats: res.consecutiveStats,
runs: res.runs,
});
setSavedAnalysisId(saved.id);
onSaved?.(saved);
} catch (err) {
setSaveError(err.message ?? 'Failed to save analysis');
} finally {
setSaving(false);
}
}
// ── Push summary to daily status ─────────────────────────────────────────────
async function pushSummary() {
if (!dailyStatusId || !results) return;
setSummaryPushing(true);
setSummaryPushed(false);
setSummaryError(null);
try {
const counts = {};
for (const [cat, data] of Object.entries(results.categoryCounts)) {
counts[cat] = data.total;
}
const total = Object.values(counts).reduce((a, b) => a + b, 0);
const successRate = counts['Success'] != null ? counts['Success'] / total : null;
await dailyStatusesApi.updateAnalysisSummary(dailyStatusId, {
counts,
total,
success_rate: successRate,
source_analysis_id: savedAnalysisId ?? null,
});
setSummaryPushed(true);
onSummaryPushed?.({ counts, total, success_rate: successRate });
} catch (err) {
setSummaryError(err.message ?? 'Failed to update');
} finally {
setSummaryPushing(false);
}
}
// ── Reset ────────────────────────────────────────────────────────────────────
function reset() {
setPhase('upload');
setRawText('');
setHeaders([]);
setRows([]);
setUniqueNotes([]);
setBuckets(animal?.csv_analysis_config?.buckets ?? DEFAULT_BUCKETS);
setResults(null);
setSavedAnalysisId(null);
setSummaryPushed(false);
setSummaryError(null);
setFrameCheck(null);
}
// ── Render ───────────────────────────────────────────────────────────────────
return (
<div className="mt-8 border-t border-gray-200 pt-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">CSV Analysis</h2>
{phase !== 'upload' && (
<button type="button" onClick={reset} className="text-xs text-gray-400 hover:text-gray-600">
Start over
</button>
)}
</div>
{/* ── PHASE: upload ── */}
{phase === 'upload' && (
<div
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`
border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-all
${isDragOver ? 'border-indigo-400 bg-indigo-50' : 'border-gray-200 hover:border-gray-300 bg-gray-50'}
`}
>
<input ref={fileInputRef} type="file" accept=".csv" className="hidden"
onChange={(e) => readFile(e.target.files[0])} />
<p className="text-sm text-gray-500">
{isDragOver ? 'Drop CSV file to upload' : 'Drop a CSV file here, or click to browse'}
</p>
<p className="text-xs text-gray-400 mt-1">Columns will be read; data stays in your browser</p>
</div>
)}
{/* ── PHASE: columns ── */}
{phase === 'columns' && (
<div className="space-y-4">
<p className="text-sm text-gray-600">
<span className="font-medium">{fileName}</span> loaded {headers.length} columns detected. Select the columns to analyse.
</p>
<div className="grid grid-cols-2 gap-4 max-w-lg">
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Timestamp column</label>
<select value={timestampCol} onChange={(e) => setTimestampCol(e.target.value)}
className="w-full text-sm border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400">
{headers.map(h => <option key={h} value={h}>{h}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Note column</label>
<select value={noteCol} onChange={(e) => setNoteCol(e.target.value)}
className="w-full text-sm border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400">
{headers.map(h => <option key={h} value={h}>{h}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Frame number column <span className="font-normal text-gray-400">(gap check)</span>
</label>
<select value={frameCol} onChange={(e) => setFrameCol(e.target.value)}
className="w-full text-sm border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400">
<option value=""> skip </option>
{headers.map(h => <option key={h} value={h}>{h}</option>)}
</select>
</div>
</div>
<Button onClick={loadData} disabled={parsePending}>
{parsePending ? 'Parsing…' : 'Load note values →'}
</Button>
</div>
)}
{/* ── PHASE: classify ── */}
{phase === 'classify' && (
<div className="space-y-5">
{/* Frame gap check results */}
{frameCheck && (
<div className={`rounded-lg border px-3 py-2.5 text-xs ${frameCheck.consecutive ? 'bg-green-50 border-green-200 text-green-800' : 'bg-amber-50 border-amber-200 text-amber-800'}`}>
<p className="font-semibold mb-1">
Frame check ({frameCol}): {frameCheck.consecutive ? `✓ all ${frameCheck.total} frames consecutive` : `⚠ issues found in ${frameCheck.total} frames`}
</p>
{frameCheck.gaps.length > 0 && (
<div className="mb-1">
<span className="font-medium">{frameCheck.gaps.length} gap{frameCheck.gaps.length !== 1 ? 's' : ''}:</span>
<ul className="mt-0.5 space-y-0.5 max-h-32 overflow-y-auto font-mono">
{frameCheck.gaps.map((g, i) => (
<li key={i}>row {g.rowIndex + 1}: expected {g.expected}, got {g.actual} (jump +{g.jump})</li>
))}
</ul>
</div>
)}
{frameCheck.duplicates.length > 0 && (
<div>
<span className="font-medium">{frameCheck.duplicates.length} duplicate{frameCheck.duplicates.length !== 1 ? 's' : ''}:</span>
<ul className="mt-0.5 space-y-0.5 font-mono max-h-20 overflow-y-auto">
{frameCheck.duplicates.map((d, i) => (
<li key={i}>row {d.rowIndex + 1}: frame {d.value}</li>
))}
</ul>
</div>
)}
</div>
)}
<p className="text-sm text-gray-600">
Found <span className="font-semibold">{uniqueNotes.length}</span> unique note value{uniqueNotes.length !== 1 ? 's' : ''}.
{selectedNote && (
<span className="ml-2 text-indigo-600 font-medium">
Selected: <span className="font-mono">{selectedNote}</span> click a category to assign
</span>
)}
</p>
{/* Unassigned chips */}
{unassignedNotes.length > 0 && (
<div>
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1.5">Unassigned</p>
<div className="flex flex-wrap gap-2">
{unassignedNotes.map(note => (
<NoteChip key={note} value={note} category={null} colors={null}
selected={selectedNote === note}
onSelect={() => setSelectedNote(prev => prev === note ? null : note)}
draggable onDragStart={() => setDraggedNote(note)}
/>
))}
</div>
</div>
)}
{/* Category buckets */}
<div className="grid grid-cols-3 gap-3">
{buckets.map((b) => {
const colors = getCategoryColors(b.name, customCategories);
const isCustom = !DEFAULT_BUCKET_NAMES.has(b.name);
return (
<CategoryBucket key={b.name} name={b.name} type={b.type} notes={b.notes} colors={colors}
isOver={overBucket === b.name}
isCustom={isCustom}
unassignedCount={unassignedNotes.length}
onDragOver={() => setOverBucket(b.name)}
onDragLeave={() => setOverBucket(null)}
onDrop={() => {
if (draggedNote) { assign(draggedNote, b.name); setDraggedNote(null); setOverBucket(null); }
}}
onClickAssign={() => { if (selectedNote) assign(selectedNote, b.name); }}
onRemoveNote={(note) => unassign(note)}
onBulkAddUnassigned={() => {
for (const n of unassignedNotes) assign(n, b.name);
}}
onRemoveBucket={() => setBuckets((prev) => prev.filter((x) => x.name !== b.name))}
/>
);
})}
</div>
{/* Add new bucket — note or numerical */}
<div className="flex items-center gap-2 flex-wrap">
{showNewCat ? (
<>
<input
autoFocus
value={newCatName}
onChange={(e) => setNewCatName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Escape') setShowNewCat(false); }}
placeholder="Field name…"
className="text-sm border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-40"
/>
<label className="text-xs text-gray-600 inline-flex items-center gap-1">
<input type="radio" name="newBucketType" value="note"
checked={newCatType === 'note'}
onChange={() => setNewCatType('note')} /> Note
</label>
<label className="text-xs text-gray-600 inline-flex items-center gap-1">
<input type="radio" name="newBucketType" value="numerical"
checked={newCatType === 'numerical'}
onChange={() => setNewCatType('numerical')} /> Numerical
</label>
<Button size="sm" type="button" onClick={() => {
addBucket(newCatName, newCatType);
setNewCatName('');
setNewCatType('note');
setShowNewCat(false);
}}>Add</Button>
<button type="button" onClick={() => setShowNewCat(false)}
className="text-xs text-gray-400 hover:text-gray-600">Cancel</button>
</>
) : (
<button type="button" onClick={() => setShowNewCat(true)}
className="text-xs text-indigo-500 hover:text-indigo-700 border border-dashed border-indigo-300 rounded-full px-3 py-0.5 hover:border-indigo-500 transition-colors">
+ Add field
</button>
)}
</div>
{/* Unclassified warning */}
{!allAssigned && uniqueNotes.length > 0 && (
<p className="text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded px-3 py-1.5">
{unassignedNotes.length} unassigned note value{unassignedNotes.length !== 1 ? 's' : ''} will be excluded from the analysis.
</p>
)}
<Button onClick={compute} disabled={uniqueNotes.length === 0}>
Run analysis
</Button>
</div>
)}
{/* ── PHASE: results ── */}
{phase === 'results' && results && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<p className="text-sm text-gray-500">
Analysed <span className="font-semibold">{results.totalNoteRows}</span> note rows
· columns: <span className="font-mono text-xs">{timestampCol}</span> / <span className="font-mono text-xs">{noteCol}</span>
</p>
{dailyStatusId && (
<span className="text-xs">
{saving && <span className="text-gray-400">Saving</span>}
{!saving && !saveError && results && <span className="text-green-600"> Saved</span>}
{saveError && <span className="text-red-500" title={saveError}> Save failed</span>}
</span>
)}
</div>
{/* Category counts */}
<div>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Category Counts</h3>
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Category</th>
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Note breakdown</th>
<th className="text-right px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Total</th>
</tr>
</thead>
<tbody>
{Object.entries(results.categoryCounts)
.sort(([, a], [, b]) => b.total - a.total)
.map(([cat, data]) => {
const colors = getCategoryColors(cat, customCategories);
const breakdown = Object.entries(data.byNote)
.sort(([, a], [, b]) => b - a)
.map(([n, c]) => `${n}: ${c}`)
.join(' · ');
return (
<tr key={cat} className="border-b border-gray-100">
<td className="px-3 py-2">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-semibold ${colors.bg} ${colors.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${colors.dot}`} />
{cat}
</span>
</td>
<td className="px-3 py-2 text-gray-500 font-mono text-xs">{breakdown}</td>
<td className="px-3 py-2 text-right font-semibold text-gray-800">{data.total}</td>
</tr>
);
})}
{Object.keys(results.categoryCounts).length === 0 && (
<tr><td colSpan={3} className="px-3 py-4 text-center text-gray-400 italic text-sm">No classified notes</td></tr>
)}
</tbody>
</table>
</div>
{/* Consecutive stats */}
{Object.keys(results.consecutiveStats).length > 0 && (
<div>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Consecutive Runs</h3>
<p className="text-xs text-gray-400 mb-2">
How many times each category appeared in a row (run-length encoding of the category sequence, ordered by timestamp).
</p>
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Category</th>
<th className="text-right px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Total runs</th>
<th className="text-right px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Max run</th>
<th className="text-right px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Avg run</th>
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Run lengths</th>
</tr>
</thead>
<tbody>
{Object.entries(results.consecutiveStats)
.sort(([, a], [, b]) => b.totalRuns - a.totalRuns)
.map(([cat, s]) => {
const colors = getCategoryColors(cat, customCategories);
const runSummary = s.runLengths
.reduce((acc, len) => {
acc[len] = (acc[len] || 0) + 1;
return acc;
}, {});
const runDisplay = Object.entries(runSummary)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([len, cnt]) => `${len}×${cnt}`)
.join(', ');
return (
<tr key={cat} className="border-b border-gray-100">
<td className="px-3 py-2">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-semibold ${colors.bg} ${colors.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${colors.dot}`} />
{cat}
</span>
</td>
<td className="px-3 py-2 text-right text-gray-700">{s.totalRuns}</td>
<td className="px-3 py-2 text-right text-gray-700">{s.maxRun}</td>
<td className="px-3 py-2 text-right text-gray-700">{s.avgRun.toFixed(2)}</td>
<td className="px-3 py-2 text-gray-500 font-mono text-xs">{runDisplay}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<RunSequenceView
runs={results.runs}
sequence={results.sequence}
customCategories={customCategories}
sessionEndTs={results.sessionEndTs}
/>
{/* Numerical plots — one panel per numerical bucket */}
{results.numericSeries && results.numericSeries.length > 0 && (
<div>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Numerical Plots</h3>
<div className="space-y-4">
{results.numericSeries.map(({ bucket, points, avg, skipped, total, xLabel }) => {
const colors = getCategoryColors(bucket.name, customCategories);
return (
<div key={bucket.name} className="border border-gray-200 rounded-lg p-3">
<div className="flex items-baseline justify-between mb-2">
<p className={`text-sm font-semibold ${colors.text}`}>
{bucket.name} <span className="text-xs text-gray-400 font-normal">vs {xLabel}</span>
</p>
<p className="text-xs text-gray-500">
{avg != null ? <>Avg: <strong className="text-gray-800">{avg.toFixed(2)}</strong> · </> : null}
{points.length}/{total} rows plottable
{skipped > 0 && <> · {skipped} skipped</>}
</p>
</div>
{points.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={points} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#eee" />
<XAxis dataKey="x" type="number" domain={['dataMin', 'dataMax']}
tick={{ fontSize: 10 }} label={{ value: xLabel, position: 'insideBottom', offset: -2, fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip />
<Line type="monotone" dataKey="value" stroke="currentColor" className={colors.text}
dot={{ r: 2 }} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
) : (
<p className="text-xs text-gray-400 italic">
No plottable values {total} of {total} note rows had no extractable number.
</p>
)}
</div>
);
})}
</div>
</div>
)}
{/* Save metrics to daily status */}
{dailyStatusId && (
<div className="pt-3 border-t border-gray-100">
<div className="flex items-center gap-3 flex-wrap">
<button type="button" onClick={pushSummary} disabled={summaryPushing}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 transition-colors">
{summaryPushing ? 'Saving…' : summaryPushed ? '✓ Metrics saved to daily status' : 'Save metrics to daily status'}
</button>
{summaryError && <span className="text-xs text-red-500">{summaryError}</span>}
</div>
{!summaryPushed && (
<p className="text-[10px] text-gray-400 mt-1">
Saves success / failure counts, total attempts, and success rate to this day's record for cross-day and cross-subject graphing.
</p>
)}
</div>
)}
{configSaveError && (
<p className="text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded px-3 py-1.5">
Bucket config didn't save: {configSaveError}. Analysis still ran. Try running again to retry.
</p>
)}
<div className="flex gap-3 pt-2 border-t border-gray-100">
<Button variant="secondary" onClick={() => setPhase('classify')}> Reclassify</Button>
<Button variant="secondary" onClick={reset}>Start over</Button>
</div>
</div>
)}
</div>
);
}
+185 -96
View File
@@ -1,68 +1,132 @@
import React, { useState, useEffect } from 'react';
import { format } from 'date-fns';
import Button from './ui/Button';
import FormField, { Input, Textarea } from './ui/FormField';
import { Input, Textarea } from './ui/FormField';
import Alert from './ui/Alert';
import { dailyStatusesApi } from '../api/client';
import { dailyStatusesApi, experimentsApi } from '../api/client';
export default function DailyStatusForm({ existing, animalId, onSuccess, onCancel }) {
const BUILTIN_KEYS = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
const EMPTY_TEMPLATE = [];
// Maps field width % to a 12-column grid span
function widthToSpan(pct) {
if (pct <= 8) return 1;
if (pct <= 17) return 2;
if (pct <= 25) return 3;
if (pct <= 33) return 4;
if (pct <= 42) return 5;
if (pct <= 50) return 6;
if (pct <= 67) return 8;
if (pct <= 75) return 9;
return 12;
}
/** Extract the value for a field from a status record.
* Custom fields are keyed by fieldId (stable UUID), not by key (display slug). */
function getValue(status, field) {
if (!status) return '';
if (field.builtin) return status[field.key] ?? '';
return status.custom_fields?.[field.fieldId] ?? '';
}
export default function DailyStatusForm({ existing, animalId, experimentId, template = EMPTY_TEMPLATE, onTemplateUpdate, onSuccess, onCancel, onRequestDelete, hasPrev, hasNext, onNavigate }) {
const today = format(new Date(), 'yyyy-MM-dd');
const [fields, setFields] = useState({
date: existing ? format(new Date(existing.date), 'yyyy-MM-dd') : today,
experiment_description: existing?.experiment_description ?? '',
vitals: existing?.vitals ?? '',
treatment: existing?.treatment ?? '',
notes: existing?.notes ?? '',
});
// Active fields from template (date is always first and handled separately)
const activeFields = template.filter((f) => f.active);
function buildInitialValues() {
const vals = { date: existing ? String(existing.date).slice(0, 10) : today };
for (const f of activeFields) {
vals[f.key] = getValue(existing, f);
}
return vals;
}
const [values, setValues] = useState(buildInitialValues);
const [errors, setErrors] = useState({});
const [apiError, setApiError] = useState(null);
const [loading, setLoading] = useState(false);
const [savingTagFor, setSavingTagFor] = useState(null); // fieldId currently being saved
useEffect(() => {
setFields({
date: existing ? format(new Date(existing.date), 'yyyy-MM-dd') : today,
experiment_description: existing?.experiment_description ?? '',
vitals: existing?.vitals ?? '',
treatment: existing?.treatment ?? '',
notes: existing?.notes ?? '',
});
setValues(buildInitialValues());
setErrors({});
setApiError(null);
}, [existing]);
}, [existing, template]);
const set = (k) => (e) => setFields((f) => ({ ...f, [k]: e.target.value }));
const set = (key) => (e) => setValues((v) => ({ ...v, [key]: e.target.value }));
async function saveTag(field) {
const value = (values[field.key] ?? '').trim();
if (!value || !experimentId || !onTemplateUpdate) return;
setSavingTagFor(field.fieldId);
try {
const updatedTemplate = template.map((f) =>
f.fieldId === field.fieldId
? { ...f, tags: [...(f.tags ?? []), value] }
: f
);
await experimentsApi.updateTemplate(experimentId, updatedTemplate);
onTemplateUpdate(updatedTemplate);
} catch (err) {
// non-critical — silently ignore tag save errors
} finally {
setSavingTagFor(null);
}
}
function validate() {
const e = {};
if (!fields.date) {
if (!values.date) {
e.date = 'Date is required';
} else if (!/^\d{4}-\d{2}-\d{2}$/.test(fields.date)) {
e.date = 'Date must be in YYYY-MM-DD format';
} else {
const d = new Date(fields.date);
if (isNaN(d.getTime())) e.date = 'Invalid date';
} else if (!/^\d{4}-\d{2}-\d{2}$/.test(values.date) || isNaN(new Date(values.date).getTime())) {
e.date = 'Date must be a valid YYYY-MM-DD date';
}
setErrors(e);
return Object.keys(e).length === 0;
}
async function doSave() {
const builtin = {};
const custom = {};
for (const f of activeFields) {
const val = values[f.key] || null;
if (f.builtin) {
builtin[f.key] = val;
} else {
custom[f.fieldId] = val;
}
}
const existingCustom = existing?.custom_fields ?? {};
const mergedCustom = { ...existingCustom, ...custom };
const payload = { date: values.date, ...builtin, custom_fields: mergedCustom };
return existing
? await dailyStatusesApi.update(existing.id, payload)
: await dailyStatusesApi.create({ ...payload, animal_id: animalId });
}
async function handleSubmit(e) {
e.preventDefault();
if (!validate()) return;
setLoading(true);
setApiError(null);
try {
const payload = {
date: fields.date,
experiment_description: fields.experiment_description || null,
vitals: fields.vitals || null,
treatment: fields.treatment || null,
notes: fields.notes || null,
};
const result = existing
? await dailyStatusesApi.update(existing.id, payload)
: await dailyStatusesApi.create({ ...payload, animal_id: animalId });
onSuccess(result);
onSuccess(await doSave());
} catch (err) {
setApiError(err);
} finally {
setLoading(false);
}
}
async function handleNavigate(direction) {
if (!validate()) return;
setLoading(true);
setApiError(null);
try {
const result = await doSave();
onNavigate(direction, result);
} catch (err) {
setApiError(err);
} finally {
@@ -71,68 +135,93 @@ export default function DailyStatusForm({ existing, animalId, onSuccess, onCance
}
return (
<form onSubmit={handleSubmit} noValidate className="space-y-4">
<form onSubmit={handleSubmit} noValidate>
{apiError && (
<Alert
type="error"
message={apiError.message}
details={apiError.details}
onDismiss={() => setApiError(null)}
/>
<Alert type="error" message={apiError.message} details={apiError.details} onDismiss={() => setApiError(null)} />
)}
<FormField label="Date" name="date" error={errors.date} required>
<Input
type="date"
name="date"
value={fields.date}
onChange={set('date')}
required
disabled={loading}
/>
</FormField>
<FormField label="Experiment Description" name="experiment_description">
<Textarea
name="experiment_description"
value={fields.experiment_description}
onChange={set('experiment_description')}
placeholder="Describe the experimental conditions…"
disabled={loading}
/>
</FormField>
<FormField label="Vitals" name="vitals">
<Input
name="vitals"
value={fields.vitals}
onChange={set('vitals')}
placeholder="e.g. HR 72bpm, Temp 37.2°C, Weight 280g"
disabled={loading}
/>
</FormField>
<FormField label="Treatment" name="treatment">
<Input
name="treatment"
value={fields.treatment}
onChange={set('treatment')}
placeholder="e.g. Drug X — 10mg/kg oral"
disabled={loading}
/>
</FormField>
<FormField label="Notes" name="notes">
<Textarea
name="notes"
value={fields.notes}
onChange={set('notes')}
placeholder="Any additional observations…"
disabled={loading}
/>
</FormField>
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>
Cancel
</Button>
<Button type="submit" loading={loading}>
{existing ? 'Save Changes' : 'Log Status'}
</Button>
<div className="grid grid-cols-12 gap-x-3 gap-y-2 mb-4">
{/* Date — always full width */}
<div className="col-span-12 flex items-center gap-2">
<label htmlFor="date" className="text-xs font-medium text-gray-500 whitespace-nowrap w-28 shrink-0 text-right">Date <span className="text-red-400">*</span></label>
<div className="w-36">
<Input type="date" name="date" value={values.date} onChange={set('date')} required disabled={loading} />
{errors.date && <p role="alert" className="text-xs text-red-500 mt-0.5">{errors.date}</p>}
</div>
</div>
{/* Dynamic fields */}
{activeFields.map((field) => {
const isTextarea = field.type === 'textarea';
const span = isTextarea ? 12 : widthToSpan(field.width ?? 100);
const fieldTags = field.tags ?? [];
const currentValue = (values[field.key] ?? '').trim();
const canSaveTag = experimentId && onTemplateUpdate && currentValue && !fieldTags.includes(currentValue) && !field.tagsDisabled;
return (
<div key={field.key} className={`col-span-${span} flex items-start gap-2`}>
<label htmlFor={field.key} className="text-xs font-medium text-gray-500 whitespace-nowrap w-28 shrink-0 text-right pt-1.5" title={field.label}>
{field.abbr || field.label}{field.unit ? <span className="font-normal text-gray-400"> ({field.unit})</span> : null}
</label>
<div className="flex-1 min-w-0">
{isTextarea ? (
<Textarea name={field.key} value={values[field.key] ?? ''} onChange={set(field.key)} disabled={loading} />
) : (
<Input name={field.key} value={values[field.key] ?? ''} onChange={set(field.key)} disabled={loading} />
)}
{(fieldTags.length > 0 || canSaveTag) && (
<div className="flex flex-wrap items-center gap-1.5 mt-1">
{fieldTags.map((tag) => (
<button key={tag} type="button" title={`Quick-fill: ${tag}`}
onClick={() => setValues((v) => ({ ...v, [field.key]: tag }))}
className="inline-flex items-center px-2 py-0.5 rounded-full bg-indigo-50 border border-indigo-200 text-indigo-700 text-xs font-medium hover:bg-indigo-100 transition-colors">
{tag}
</button>
))}
{canSaveTag && (
<button type="button" onClick={() => saveTag(field)} disabled={savingTagFor === field.fieldId}
title="Save current value as a quick-fill tag"
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-dashed border-indigo-300 text-indigo-500 text-xs hover:border-indigo-500 hover:text-indigo-700 transition-colors disabled:opacity-50">
{savingTagFor === field.fieldId ? '…' : '+ Save as tag'}
</button>
)}
</div>
)}
</div>
</div>
);
})}
</div>
{activeFields.length === 0 && (
<p className="text-sm text-gray-400 italic text-center py-2">
No fields configured. Edit the experiment's Record Template to add fields.
</p>
)}
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
<div>
{existing && onRequestDelete && (
<Button variant="danger" type="button" onClick={onRequestDelete} disabled={loading}>Delete entry</Button>
)}
</div>
<div className="flex items-center gap-3">
{existing && onNavigate && (
<div className="flex gap-1">
<button type="button" onClick={() => handleNavigate('prev')} disabled={loading || !hasPrev}
title="Save & go to previous entry"
className="px-2 py-1 rounded border border-gray-300 text-gray-500 text-sm hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
</button>
<button type="button" onClick={() => handleNavigate('next')} disabled={loading || !hasNext}
title="Save & go to next entry"
className="px-2 py-1 rounded border border-gray-300 text-gray-500 text-sm hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
</button>
</div>
)}
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>Cancel</Button>
<Button type="submit" loading={loading}>{existing ? 'Save Changes' : 'Log Status'}</Button>
</div>
</div>
</form>
);
@@ -0,0 +1,99 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
format, startOfMonth, endOfMonth, eachDayOfInterval,
getDay, addMonths, subMonths,
} from 'date-fns';
import { useNavigate } from 'react-router-dom';
import { experimentsApi } from '../api/client';
export default function ExperimentCalendar({ experimentId, template }) {
const navigate = useNavigate();
const [currentMonth, setCurrentMonth] = useState(() => new Date());
const [selectedField, setSelectedField] = useState(null);
const [calendarDays, setCalendarDays] = useState({});
const fieldOptions = useMemo(
() => template.filter((f) => f.active).map((f) => ({ value: f.builtin ? f.key : f.fieldId, label: f.label })),
[template],
);
useEffect(() => {
if (fieldOptions.length === 0 || selectedField) return;
const weightField = fieldOptions.find((f) => f.label.toLowerCase().includes('weight'));
setSelectedField(weightField ? weightField.value : fieldOptions[0]?.value ?? null);
}, [fieldOptions]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!selectedField) return;
let cancelled = false;
experimentsApi.getCalendar(experimentId, selectedField)
.then(({ days }) => { if (!cancelled) setCalendarDays(days); })
.catch(() => {});
return () => { cancelled = true; };
}, [experimentId, selectedField]);
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
const leadingPad = getDay(monthStart);
return (
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm mt-6">
<h3 className="text-base font-semibold text-gray-900 mb-4">Daily Record Calendar</h3>
<div className="flex flex-wrap items-center gap-3 mb-5">
<span className="text-sm text-gray-600 font-medium">Count by field:</span>
<select
data-testid="field-select"
className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
value={selectedField ?? ''}
onChange={(e) => setSelectedField(e.target.value)}
disabled={fieldOptions.length === 0}
>
{fieldOptions.map((f) => <option key={f.value} value={f.value}>{f.label}</option>)}
{fieldOptions.length === 0 && <option value="">No fields configured</option>}
</select>
</div>
<div className="flex items-center justify-between mb-4">
<button aria-label="Previous month" onClick={() => setCurrentMonth((m) => subMonths(m, 1))}
className="p-2 rounded-lg hover:bg-gray-100 text-gray-500 text-lg leading-none">&#8249;</button>
<span className="font-semibold text-gray-900 text-base">{format(currentMonth, 'MMMM yyyy')}</span>
<button aria-label="Next month" onClick={() => setCurrentMonth((m) => addMonths(m, 1))}
className="p-2 rounded-lg hover:bg-gray-100 text-gray-500 text-lg leading-none">&#8250;</button>
</div>
<div className="grid grid-cols-7 mb-1">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => (
<div key={d} className="text-center text-xs font-medium text-gray-400 py-1 select-none">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{Array.from({ length: leadingPad }).map((_, i) => <div key={`pad-${i}`} />)}
{days.map((day) => {
const dateStr = format(day, 'yyyy-MM-dd');
const count = calendarDays[dateStr] ?? 0;
const hasData = count > 0;
return (
<button
key={dateStr}
data-testid={`day-${dateStr}`}
disabled={!hasData}
onClick={() => navigate(`/experiments/${experimentId}/day/${dateStr}${selectedField ? `?field=${selectedField}` : ''}`)}
className={[
'relative flex flex-col items-center justify-center rounded-lg py-1.5 min-h-[2.75rem] text-sm select-none transition-all',
hasData ? 'bg-blue-50 border border-blue-200 hover:bg-blue-100 cursor-pointer' : 'text-gray-300 cursor-default',
].join(' ')}
>
<span className={hasData ? 'font-semibold text-blue-900 text-xs' : 'text-xs'}>{day.getDate()}</span>
{hasData && (
<span data-testid={`count-${dateStr}`} className="text-[10px] leading-none font-bold text-blue-600 mt-0.5">{count}</span>
)}
</button>
);
})}
</div>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
import React, { useEffect, useMemo, useState } from 'react';
import { experimentsApi } from '../api/client';
import Button from './ui/Button';
import Alert from './ui/Alert';
import { listExportableParams, buildMatrix, toCSV, csvFilename } from '../lib/dataExport';
const GROUP_LABELS = { metrics: 'Session metrics', daily: 'Daily record fields' };
function triggerDownload(filename, text) {
const blob = new Blob([text], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 0);
}
export default function ExportDataModal({ experimentTitle, experimentId, dailyTemplate, animals, onClose }) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [statuses, setStatuses] = useState([]);
const [selectedId, setSelectedId] = useState(null);
useEffect(() => {
let alive = true;
setLoading(true);
setError(null);
experimentsApi.getDailyStatuses(experimentId, {})
.then((data) => { if (alive) setStatuses(data); })
.catch((err) => { if (alive) setError(err.message); })
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [experimentId]);
const params = useMemo(() => listExportableParams(dailyTemplate, statuses), [dailyTemplate, statuses]);
// Derive the effective selection: the user's pick, else the first parameter.
const effectiveId = selectedId ?? params[0]?.id ?? null;
const selectedParam = params.find((p) => p.id === effectiveId) ?? null;
const matrix = useMemo(
() => (selectedParam ? buildMatrix(statuses, animals, selectedParam) : { columns: [], rows: [] }),
[statuses, animals, selectedParam],
);
const hasData = matrix.rows.length > 0;
// Group params for <optgroup> rendering, preserving order.
const grouped = useMemo(() => {
const out = [];
for (const p of params) {
let g = out.find((x) => x.group === p.group);
if (!g) { g = { group: p.group, items: [] }; out.push(g); }
g.items.push(p);
}
return out;
}, [params]);
function handleExport() {
if (!hasData || !selectedParam) return;
triggerDownload(csvFilename(experimentTitle, selectedParam.label), toCSV(matrix));
onClose();
}
if (loading) return <p className="text-sm text-gray-400" aria-live="polite">Loading data</p>;
if (error) return (
<div className="space-y-4">
<Alert type="error" message={error} />
<div className="flex justify-end">
<Button variant="secondary" onClick={onClose}>Close</Button>
</div>
</div>
);
return (
<div className="space-y-4">
<p className="text-sm text-gray-500">
Export one parameter as a CSV file each row is a date, each column is a subject.
</p>
<div>
<label htmlFor="export-param" className="block text-xs font-medium text-gray-500 mb-1">Parameter</label>
<select
id="export-param"
value={effectiveId ?? ''}
onChange={(e) => setSelectedId(e.target.value)}
className="w-full border border-gray-200 rounded px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400"
>
{grouped.map((g) => (
<optgroup key={g.group} label={GROUP_LABELS[g.group] ?? g.group}>
{g.items.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
</optgroup>
))}
</select>
</div>
<p className="text-xs text-gray-400">
{hasData
? `${matrix.rows.length} date${matrix.rows.length !== 1 ? 's' : ''} × ${matrix.columns.length} subject${matrix.columns.length !== 1 ? 's' : ''}`
: 'No data for this parameter yet.'}
</p>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button onClick={handleExport} disabled={!hasData}>Export CSV</Button>
</div>
</div>
);
}
+431
View File
@@ -0,0 +1,431 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { sessionFilesApi } from '../api/client';
// ── Helpers ───────────────────────────────────────────────────────────────────
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Build a regex that matches an animal's identifier in a filename.
* Tries both animal_id_string and animal_name (OR logic).
*
* Rules:
* - Match must not be preceded or followed by an alphanumeric char,
* so "2852" matches "rat_2852_session" but not "28520" or "X2852Y".
* - Multi-word names ("Rat A") allow any separator (space, dash, underscore)
* between words, so "Rat A" matches "rat_a_session" and "Rat-A-data".
*/
function buildAnimalRegex(animal) {
const patterns = [];
if (animal.animal_id_string) {
patterns.push(escapeRegex(animal.animal_id_string));
}
if (animal.animal_name) {
const words = animal.animal_name.trim().split(/\s+/).map(escapeRegex);
// Single-word name: use as-is. Multi-word: allow space/dash/underscore between words.
patterns.push(words.length === 1 ? words[0] : words.join('[\\s_\\-]+'));
}
if (patterns.length === 0) return null;
return new RegExp(`(?<![a-zA-Z0-9])(${patterns.join('|')})(?![a-zA-Z0-9])`, 'i');
}
function classifyFile(filename) {
const ext = filename.split('.').pop().toLowerCase();
const MAP = { csv: 'CSV', tsv: 'TSV', h5: 'HDF5', hdf5: 'HDF5', nwb: 'NWB', bin: 'Binary', dat: 'Binary', raw: 'Binary', npy: 'NumPy', npz: 'NumPy', mat: 'MATLAB', pkl: 'Pickle' };
return MAP[ext] ?? 'Binary';
}
function fmtBytes(n) {
const num = typeof n === 'bigint' ? Number(n) : Number(n);
if (num >= 1e9) return `${(num / 1e9).toFixed(1)} GB`;
if (num >= 1e6) return `${(num / 1e6).toFixed(1)} MB`;
if (num >= 1e3) return `${(num / 1e3).toFixed(1)} KB`;
return `${num} B`;
}
/**
* Match an array of File objects against animals.
* Returns {
* matched: Map<animalId, Array<{ file, matchedIdentifier }>>
* unmatched: File[]
* }
* matchedIdentifier is the animal_id_string or animal_name value that triggered the match.
*/
function matchFilesToAnimals(files, animals) {
// Build patterns; each entry carries both alternatives so we know which one matched.
const entries = animals
.map((animal) => {
const alts = [];
if (animal.animal_id_string) alts.push({ value: animal.animal_id_string, re: new RegExp(`(?<![a-zA-Z0-9])${escapeRegex(animal.animal_id_string)}(?![a-zA-Z0-9])`, 'i') });
if (animal.animal_name) {
const words = animal.animal_name.trim().split(/\s+/).map(escapeRegex);
const pat = words.length === 1 ? words[0] : words.join('[\\s_\\-]+');
alts.push({ value: animal.animal_name, re: new RegExp(`(?<![a-zA-Z0-9])${pat}(?![a-zA-Z0-9])`, 'i') });
}
return { animal, alts };
})
.filter((e) => e.alts.length > 0);
const matched = new Map(animals.map((a) => [a.id, []]));
const unmatched = [];
for (const file of files) {
let found = false;
for (const { animal, alts } of entries) {
for (const { value, re } of alts) {
if (re.test(file.name)) {
matched.get(animal.id).push({ file, matchedIdentifier: value });
found = true;
break;
}
}
if (found) break;
}
if (!found) unmatched.push(file);
}
return { matched, unmatched };
}
// ── Sub-components ────────────────────────────────────────────────────────────
function FileRow({ file, matchedIdentifier, onRemove }) {
return (
<div className="flex items-center justify-between py-1 gap-2 text-xs">
<span className="font-mono text-gray-700 truncate min-w-0 flex-1">{file.name}</span>
{matchedIdentifier && (
<span className="text-blue-400 shrink-0 font-mono" title="matched by"> {matchedIdentifier}</span>
)}
<span className="text-gray-400 shrink-0">{fmtBytes(file.size)}</span>
<span className="text-gray-400 shrink-0 w-12 text-right">{classifyFile(file.name)}</span>
{onRemove && (
<button type="button" onClick={onRemove}
className="text-red-300 hover:text-red-500 transition-colors shrink-0 ml-1"></button>
)}
</div>
);
}
function SavedFileRow({ file, onDelete, deleting }) {
const size = typeof file.file_size === 'string' ? BigInt(file.file_size) : file.file_size;
return (
<div className="flex items-center justify-between py-1 gap-2 text-xs">
<span className="font-mono text-gray-600 truncate min-w-0 flex-1">{file.filename}</span>
<span className="text-gray-400 shrink-0">{fmtBytes(size)}</span>
<span className="text-gray-400 shrink-0 w-12 text-right">{file.file_type ?? '?'}</span>
<button type="button" onClick={onDelete} disabled={deleting}
className="text-red-300 hover:text-red-500 transition-colors shrink-0 ml-1 disabled:opacity-40"></button>
</div>
);
}
// ── Main component ────────────────────────────────────────────────────────────
/**
* FileDropZone
*
* Props:
* rows — array of { animal, status } from ExperimentDayView
* (only rows with a status are shown; status.id is required to save files)
* date — YYYY-MM-DD string, shown in naming hint
*/
export default function FileDropZone({ rows, date }) {
const [isDragging, setIsDragging] = useState(false);
const [dropped, setDropped] = useState(null); // { matched: Map, unmatched: File[] } | null
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState(null);
const [savedByStatus, setSavedByStatus] = useState({}); // statusId → SavedFile[]
const [loadingIds, setLoadingIds] = useState(new Set());
const [deletingId, setDeletingId] = useState(null);
const dragCounter = useRef(0);
const statusRows = rows.filter((r) => r.status);
const animals = statusRows.map((r) => r.animal);
// Load existing saved files for all statuses in this day view
useEffect(() => {
if (statusRows.length === 0) return;
const ids = statusRows.map((r) => r.status.id);
setLoadingIds(new Set(ids));
Promise.all(
ids.map((sid) => sessionFilesApi.list(sid).then((files) => ({ sid, files })))
).then((results) => {
const map = {};
results.forEach(({ sid, files }) => { map[sid] = files; });
setSavedByStatus(map);
setLoadingIds(new Set());
}).catch(() => setLoadingIds(new Set()));
}, [statusRows.map((r) => r.status?.id).join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Drag handlers ──────────────────────────────────────────────────────────
const onDragEnter = useCallback((e) => {
e.preventDefault();
dragCounter.current += 1;
if (dragCounter.current === 1) setIsDragging(true);
}, []);
const onDragLeave = useCallback((e) => {
e.preventDefault();
dragCounter.current -= 1;
if (dragCounter.current === 0) setIsDragging(false);
}, []);
const onDragOver = useCallback((e) => { e.preventDefault(); }, []);
const onDrop = useCallback((e) => {
e.preventDefault();
dragCounter.current = 0;
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
setDropped(matchFilesToAnimals(files, animals));
setSaveError(null);
}, [animals]);
function removeMatchedFile(animalId, fileIdx) {
setDropped((prev) => {
const next = new Map(prev.matched);
const arr = [...next.get(animalId)];
arr.splice(fileIdx, 1);
next.set(animalId, arr);
return { matched: next, unmatched: prev.unmatched };
});
}
function removeUnmatched(idx) {
setDropped((prev) => {
const u = [...prev.unmatched];
u.splice(idx, 1);
return { matched: prev.matched, unmatched: u };
});
}
// ── Save ───────────────────────────────────────────────────────────────────
async function save() {
if (!dropped) return;
setSaving(true);
setSaveError(null);
try {
const results = await Promise.all(
statusRows.map(async ({ animal, status }) => {
const entries = dropped.matched.get(animal.id) ?? [];
if (entries.length === 0) return { statusId: status.id, saved: [] };
const payload = entries.map(({ file, matchedIdentifier }) => ({
filename: file.name,
file_size: file.size,
file_type: classifyFile(file.name),
last_modified: file.lastModified ?? null,
matched_identifier: matchedIdentifier,
animal_id_string_snap: animal.animal_id_string ?? null,
animal_name_snap: animal.animal_name ?? null,
}));
const saved = await sessionFilesApi.create(status.id, payload);
return { statusId: status.id, saved };
})
);
setSavedByStatus((prev) => {
const next = { ...prev };
results.forEach(({ statusId, saved }) => {
next[statusId] = [...(next[statusId] ?? []), ...saved];
});
return next;
});
setDropped(null);
} catch (err) {
setSaveError(err.message ?? 'Failed to save file metadata');
} finally {
setSaving(false);
}
}
// ── Delete ─────────────────────────────────────────────────────────────────
async function deleteFile(fileId, statusId) {
setDeletingId(fileId);
try {
await sessionFilesApi.delete(fileId);
setSavedByStatus((prev) => ({
...prev,
[statusId]: prev[statusId].filter((f) => f.id !== fileId),
}));
} catch {
// silently ignore — UI stays consistent, user can retry
} finally {
setDeletingId(null);
}
}
// ── Derived counts ─────────────────────────────────────────────────────────
const totalDropped = dropped ? [...dropped.matched.values()].reduce((n, a) => n + a.length, 0) : 0; // entries are { file, matchedIdentifier }
const totalUnmatched = dropped?.unmatched.length ?? 0;
const totalSaved = Object.values(savedByStatus).reduce((n, a) => n + a.length, 0);
const hasSaved = totalSaved > 0;
const hasMatchedDropped = totalDropped > 0;
// Hint string for naming convention
const sampleId = animals[0]?.animal_id_string || animals[0]?.animal_name || '<id>';
const namingHint = `${date}_${sampleId}_session1.csv`;
return (
<div className="mt-8">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Session File Registry</h3>
<p className="text-xs text-gray-400 mb-4">
Drop all session files here. Filenames are matched to subjects by ID
only metadata (name, size) is recorded; files are never uploaded.
<br />
Naming convention: <span className="font-mono text-gray-500">{namingHint}</span>
</p>
{/* Drop zone */}
<div
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
data-testid="drop-zone"
className={[
'border-2 border-dashed rounded-xl px-6 py-10 text-center transition-all cursor-default select-none',
isDragging
? 'border-blue-400 bg-blue-50'
: 'border-gray-200 bg-gray-50 hover:border-gray-300',
].join(' ')}
>
{isDragging ? (
<p className="text-blue-600 font-medium text-sm">Release to register files</p>
) : (
<>
<p className="text-gray-400 text-sm">Drag &amp; drop session files here</p>
<p className="text-gray-300 text-xs mt-1">All subjects at once · files are not uploaded</p>
</>
)}
</div>
{/* Dropped files preview */}
{dropped && (
<div className="mt-4 space-y-4" data-testid="drop-preview">
{/* Per-subject matched files */}
{statusRows.map(({ animal, status }) => {
const entries = dropped.matched.get(animal.id) ?? [];
if (entries.length === 0) return null;
return (
<div key={animal.id} className="bg-white border border-gray-200 rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-semibold text-gray-800">
{animal.animal_name}
{animal.animal_id_string && (
<span className="ml-1.5 text-xs font-normal text-gray-400">{animal.animal_id_string}</span>
)}
</span>
<span className="text-xs text-gray-400">{entries.length} file{entries.length !== 1 ? 's' : ''}</span>
</div>
<div className="divide-y divide-gray-50">
{entries.map(({ file, matchedIdentifier }, i) => (
<FileRow key={file.name + i} file={file} matchedIdentifier={matchedIdentifier} onRemove={() => removeMatchedFile(animal.id, i)} />
))}
</div>
</div>
);
})}
{/* Subjects with no matched files */}
{statusRows.some(({ animal }) => (dropped.matched.get(animal.id) ?? []).length === 0) && (
<div className="bg-amber-50 border border-amber-100 rounded-xl p-4">
<p className="text-xs font-semibold text-amber-700 mb-1">No files matched for:</p>
{statusRows
.filter(({ animal }) => (dropped.matched.get(animal.id) ?? []).length === 0)
.map(({ animal }) => (
<span key={animal.id} className="inline-block text-xs text-amber-600 bg-amber-100 rounded px-2 py-0.5 mr-1 mb-1 font-mono">
{animal.animal_id_string || animal.animal_name}
</span>
))}
</div>
)}
{/* Unmatched files */}
{dropped.unmatched.length > 0 && (
<div className="bg-gray-50 border border-dashed border-gray-200 rounded-xl p-4">
<p className="text-xs font-semibold text-gray-500 mb-2">
Unmatched ({dropped.unmatched.length}) no subject ID found in filename
</p>
<div className="divide-y divide-gray-100">
{dropped.unmatched.map((f, i) => (
<FileRow key={f.name + i} file={f} onRemove={() => removeUnmatched(i)} />
))}
</div>
</div>
)}
{saveError && (
<p className="text-xs text-red-500 px-1">{saveError}</p>
)}
{/* Action row */}
<div className="flex items-center gap-3 pt-1">
{hasMatchedDropped && (
<button
type="button"
onClick={save}
disabled={saving}
data-testid="save-btn"
className="px-4 py-2 rounded-lg text-sm font-medium bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{saving ? 'Saving…' : `Register ${totalDropped} file${totalDropped !== 1 ? 's' : ''}`}
</button>
)}
<button
type="button"
onClick={() => { setDropped(null); setSaveError(null); }}
className="text-sm text-gray-400 hover:text-gray-600"
>
Clear
</button>
</div>
</div>
)}
{/* Saved files */}
{hasSaved && (
<div className="mt-6 space-y-3" data-testid="saved-files">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Registered files ({totalSaved})
</p>
{statusRows.map(({ animal, status }) => {
const files = savedByStatus[status.id] ?? [];
if (files.length === 0) return null;
return (
<div key={animal.id} className="bg-white border border-gray-200 rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-semibold text-gray-800">
{animal.animal_name}
{animal.animal_id_string && (
<span className="ml-1.5 text-xs font-normal text-gray-400">{animal.animal_id_string}</span>
)}
</span>
<span className="text-xs text-gray-400">{files.length} file{files.length !== 1 ? 's' : ''}</span>
</div>
<div className="divide-y divide-gray-50">
{files.map((f) => (
<SavedFileRow
key={f.id}
file={f}
onDelete={() => deleteFile(f.id, status.id)}
deleting={deletingId === f.id}
/>
))}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
+343
View File
@@ -0,0 +1,343 @@
import React, { useState, useEffect, useMemo } from 'react';
// ── Colors ─────────────────────────────────────────────────────────────────────
const BASE_COLORS = {
Success: { bg: 'bg-green-100', border: 'border-green-300', text: 'text-green-800', dot: 'bg-green-500', badge: 'bg-green-500' },
Failure: { bg: 'bg-red-100', border: 'border-red-300', text: 'text-red-800', dot: 'bg-red-500', badge: 'bg-red-500' },
Other: { bg: 'bg-gray-100', border: 'border-gray-300', text: 'text-gray-700', dot: 'bg-gray-400', badge: 'bg-gray-400' },
};
const EXTRA_COLORS = [
{ bg: 'bg-indigo-100', border: 'border-indigo-300', text: 'text-indigo-800', dot: 'bg-indigo-500', badge: 'bg-indigo-500' },
{ bg: 'bg-yellow-100', border: 'border-yellow-300', text: 'text-yellow-800', dot: 'bg-yellow-500', badge: 'bg-yellow-500' },
{ bg: 'bg-purple-100', border: 'border-purple-300', text: 'text-purple-800', dot: 'bg-purple-500', badge: 'bg-purple-500' },
{ bg: 'bg-pink-100', border: 'border-pink-300', text: 'text-pink-800', dot: 'bg-pink-500', badge: 'bg-pink-500' },
];
function getColors(cat, customCats = []) {
if (BASE_COLORS[cat]) return BASE_COLORS[cat];
return EXTRA_COLORS[Math.max(0, customCats.indexOf(cat)) % EXTRA_COLORS.length];
}
const ORDINALS = ['1st', '2nd', '3rd', '4th', '5th'];
// ── Helpers ────────────────────────────────────────────────────────────────────
/** Build display items from a sequence slice, respecting mode + collapsibility. */
function buildItems(seqSlice, mode, collapsible) {
const categorised = seqSlice.filter(s => s.category);
if (mode === 'discrete') {
return categorised.map((s, i) => ({ id: i, category: s.category, count: 1 }));
}
// Rebuild runs within this slice, then apply collapsibility
const runs = [];
for (const s of categorised) {
if (runs.length === 0 || runs[runs.length - 1].category !== s.category) {
runs.push({ category: s.category, length: 1 });
} else {
runs[runs.length - 1].length++;
}
}
const items = [];
for (const run of runs) {
if (collapsible[run.category] === false) {
for (let i = 0; i < run.length; i++) items.push({ id: items.length, category: run.category, count: 1 });
} else {
items.push({ id: items.length, category: run.category, count: run.length });
}
}
return items;
}
/** Compute per-category counts and total for a sequence slice. */
function segStats(seqSlice) {
const byCat = {};
for (const s of seqSlice) {
if (!s.category) continue;
byCat[s.category] = (byCat[s.category] || 0) + 1;
}
const total = Object.values(byCat).reduce((a, b) => a + b, 0);
return { byCat, total };
}
// ── Component ──────────────────────────────────────────────────────────────────
/**
* Props:
* runs {category, length}[] — global run-length-encoded sequence
* sequence {note, category, timestamp}[]
* customCategories string[]
*/
export default function RunSequenceView({ runs = [], sequence = [], customCategories = [], sessionEndTs = null }) {
const [mode, setMode] = useState('grouped');
const [collapsible, setCollapsible] = useState({});
const [splitN, setSplitN] = useState(1);
const [durationMins, setDurationMins] = useState(20);
const [selectedSegment, setSelectedSegment] = useState(null);
// Initialise collapsibility for any new categories
const allCats = useMemo(() => [...new Set(runs.map(r => r.category))], [runs]);
useEffect(() => {
setCollapsible(prev => {
const next = { ...prev };
for (const cat of allCats) {
if (!(cat in next)) next[cat] = cat !== 'Success';
}
return next;
});
}, [allCats]);
// Reset selected segment when split changes
useEffect(() => { setSelectedSegment(null); }, [splitN]);
// ── Timestamp bounds ─────────────────────────────────────────────────────────
const { startTs, segSize, windowUnderrun } = useMemo(() => {
let endT = sessionEndTs != null ? sessionEndTs : null;
if (endT === null) {
const ts = sequence.map(s => parseFloat(s.timestamp)).filter(t => !isNaN(t));
endT = ts.length ? Math.max(...ts) : durationMins * 60;
}
const rawStart = endT - durationMins * 60;
return {
startTs: rawStart,
segSize: (durationMins * 60) / Math.max(splitN, 1),
windowUnderrun: rawStart < 0 ? Math.abs(rawStart) : 0,
};
}, [sequence, sessionEndTs, durationMins, splitN]);
function segIndexOf(timestamp) {
const t = parseFloat(timestamp);
if (isNaN(t)) return 0;
return Math.min(Math.max(Math.floor((t - startTs) / segSize), 0), splitN - 1);
}
// ── Segment data (only when split > 1) ───────────────────────────────────────
const segmentData = useMemo(() => {
if (splitN === 1) return null;
const groups = Array.from({ length: splitN }, () => []);
for (const s of sequence) {
groups[segIndexOf(s.timestamp)].push(s);
}
return groups.map((grp, i) => ({
index: i,
items: buildItems(grp, mode, collapsible),
stats: segStats(grp),
}));
}, [splitN, sequence, mode, collapsible, startTs, segSize]);
// ── Single-segment display items (split = 1) ─────────────────────────────────
const singleItems = useMemo(() => {
if (splitN !== 1) return [];
if (mode === 'discrete') {
const src = sequence.length > 0
? sequence.filter(s => s.category)
: runs.flatMap(r => Array.from({ length: r.length }, () => ({ category: r.category })));
return src.map((s, i) => ({ id: i, category: s.category, count: 1 }));
}
const items = [];
for (const run of runs) {
if (collapsible[run.category] === false) {
for (let i = 0; i < run.length; i++) items.push({ id: items.length, category: run.category, count: 1 });
} else {
items.push({ id: items.length, category: run.category, count: run.length });
}
}
return items;
}, [splitN, mode, runs, sequence, collapsible]);
// ── Global totals ─────────────────────────────────────────────────────────────
const globalStats = useMemo(() => segStats(sequence), [sequence]);
if (runs.length === 0) return null;
const hasTimestamps = sequence.some(s => !isNaN(parseFloat(s.timestamp)));
const activeSegStats = selectedSegment !== null && segmentData ? segmentData[selectedSegment]?.stats ?? null : null;
// ── Render ────────────────────────────────────────────────────────────────────
return (
<div>
{/* ── Controls row ── */}
<div className="flex items-center flex-wrap gap-x-4 gap-y-1.5 mb-2">
{/* Title */}
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Run sequence ({runs.length} runs)
</p>
{/* Mode toggle */}
<div className="flex rounded border border-gray-200 overflow-hidden text-xs">
<button type="button" onClick={() => setMode('discrete')}
className={`px-2 py-0.5 transition-colors ${mode === 'discrete' ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
Discrete
</button>
<button type="button" onClick={() => setMode('grouped')}
className={`px-2 py-0.5 border-l border-gray-200 transition-colors ${mode === 'grouped' ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
Grouped
</button>
</div>
{/* Time split */}
{hasTimestamps && (
<div className="flex items-center gap-1.5 text-xs">
<span className="text-gray-400">Split:</span>
<select value={splitN} onChange={e => setSplitN(Number(e.target.value))}
className="border border-gray-200 rounded px-1.5 py-0.5 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400">
<option value={1}>None</option>
<option value={2}>½</option>
<option value={3}></option>
<option value={4}>¼</option>
<option value={5}></option>
</select>
<input
type="number" min={1} max={600} value={durationMins}
onChange={e => setDurationMins(Math.max(1, Number(e.target.value)))}
className="border border-gray-200 rounded px-1.5 py-0.5 w-14 text-xs text-right focus:outline-none focus:ring-1 focus:ring-indigo-400"
title="Experiment duration in minutes"
/>
<span className="text-gray-400">min</span>
</div>
)}
</div>
{/* ── Per-category collapse toggles (grouped mode) ── */}
{mode === 'grouped' && allCats.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{allCats.map(cat => {
const c = getColors(cat, customCategories);
const isGrouped = collapsible[cat] !== false;
return (
<button key={cat} type="button"
onClick={() => setCollapsible(prev => ({ ...prev, [cat]: !prev[cat] }))}
title={isGrouped ? 'Grouped — click for discrete' : 'Discrete — click to group'}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs border transition-colors
${isGrouped ? `${c.bg} ${c.border} ${c.text}` : `bg-white ${c.border} ${c.text} opacity-70`}`}>
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
{cat}: {isGrouped ? 'grouped' : 'discrete'}
</button>
);
})}
</div>
)}
{/* ── Underrun warning ── */}
{splitN > 1 && windowUnderrun > 0 && (() => {
const u = Math.round(windowUnderrun);
const m = Math.floor(u / 60), s = u % 60;
return (
<p className="text-[10px] text-amber-600 bg-amber-50 border border-amber-200 rounded px-2 py-1 mb-2">
The {durationMins}-min window starts {m}:{String(s).padStart(2,'0')} before the recording the 1st segment includes unrecorded time.
</p>
);
})()}
{/* ── Segment buttons (when split > 1) ── */}
{splitN > 1 && segmentData && (
<div className="flex flex-wrap gap-1.5 mb-2">
{segmentData.map((seg, si) => {
const isActive = selectedSegment === si;
const segStart = startTs + si * segSize;
const segEnd = startTs + (si + 1) * segSize;
const fmt = s => {
const t = Math.round(s);
const clamped = Math.max(0, t);
const m = Math.floor(clamped / 60);
const sec = clamped % 60;
const prefix = t < 0 ? '0:00*' : `${m}:${String(sec).padStart(2, '0')}`;
return prefix;
};
return (
<button key={si} type="button"
onClick={() => setSelectedSegment(prev => prev === si ? null : si)}
title={`${fmt(segStart)} ${fmt(segEnd)}`}
className={`inline-flex flex-col items-center px-2.5 py-1 rounded-lg text-xs font-medium border transition-all
${isActive ? 'bg-gray-800 text-white border-gray-800' : 'bg-white text-gray-600 border-gray-300 hover:border-gray-400'}`}>
<span className="flex items-center gap-1.5">
{ORDINALS[si]}
<span className={`text-[10px] px-1 py-px rounded-full font-semibold
${isActive ? 'bg-white/20 text-white' : 'bg-gray-100 text-gray-500'}`}>
{seg.stats.total}
</span>
</span>
<span className={`text-[9px] font-normal tabular-nums ${isActive ? 'text-gray-300' : 'text-gray-400'}`}>
{fmt(segStart)}{fmt(segEnd)}
</span>
</button>
);
})}
{selectedSegment !== null && (
<button type="button" onClick={() => setSelectedSegment(null)}
className="text-xs text-gray-400 hover:text-gray-600 px-1"></button>
)}
</div>
)}
{/* ── Selected segment stats ── */}
{activeSegStats && (
<div className="flex flex-wrap items-center gap-3 mb-2 px-3 py-2 bg-gray-50 rounded-lg border border-gray-200 text-xs">
<span className="font-semibold text-gray-700">{ORDINALS[selectedSegment]} segment</span>
<span className="text-gray-500">{activeSegStats.total} attempt{activeSegStats.total !== 1 ? 's' : ''}</span>
{Object.entries(activeSegStats.byCat).sort(([, a], [, b]) => b - a).map(([cat, n]) => {
const c = getColors(cat, customCategories);
return (
<span key={cat} className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full ${c.bg} ${c.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
{cat}: {n}
</span>
);
})}
</div>
)}
{/* ── Chip area ── */}
<div className="flex flex-wrap gap-1 items-center">
{splitN === 1
? singleItems.map(item => <Chip key={item.id} item={item} customCategories={customCategories} dimmed={false} />)
: segmentData && segmentData.map((seg, si) => (
<React.Fragment key={si}>
{si > 0 && (
<div className="flex flex-col items-center mx-0.5 self-stretch" aria-hidden>
<div className="w-px flex-1 bg-gray-300" style={{ minHeight: 20 }} />
</div>
)}
{seg.items.length === 0
? <span className="text-[10px] text-gray-300 italic px-1">empty</span>
: seg.items.map(item => (
<Chip key={`${si}-${item.id}`} item={item} customCategories={customCategories}
dimmed={selectedSegment !== null && selectedSegment !== si} />
))}
</React.Fragment>
))
}
</div>
{/* ── Global totals ── */}
<div className="flex flex-wrap items-center gap-3 mt-2 pt-2 border-t border-gray-100 text-xs text-gray-500">
<span className="font-medium text-gray-700">Total: {globalStats.total} attempt{globalStats.total !== 1 ? 's' : ''}</span>
{Object.entries(globalStats.byCat).sort(([, a], [, b]) => b - a).map(([cat, n]) => {
const c = getColors(cat, customCategories);
return (
<span key={cat} className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full ${c.bg} ${c.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
{cat}: {n}
</span>
);
})}
</div>
</div>
);
}
function Chip({ item, customCategories, dimmed }) {
const c = getColors(item.category, customCategories);
return (
<span
title={item.count > 1 ? `${item.category} × ${item.count}` : item.category}
className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono border transition-opacity
${c.bg} ${c.text} ${c.border} ${dimmed ? 'opacity-25' : 'opacity-100'}`}>
{item.count > 1 ? `${item.count}×` : ''}{item.category.slice(0, 3)}
</span>
);
}
+146
View File
@@ -0,0 +1,146 @@
import React, { useState, useEffect } from 'react';
import Button from './ui/Button';
import { Input, Textarea } from './ui/FormField';
function widthToSpan(pct) {
if (pct <= 8) return 1;
if (pct <= 17) return 2;
if (pct <= 25) return 3;
if (pct <= 33) return 4;
if (pct <= 42) return 5;
if (pct <= 50) return 6;
if (pct <= 67) return 8;
if (pct <= 75) return 9;
return 12;
}
import Alert from './ui/Alert';
import { animalsApi, experimentsApi } from '../api/client';
export default function SubjectInfoForm({ animal, subjectTemplate = [], experimentId, onTemplateUpdate, onSaved, onCancel }) {
const activeFields = subjectTemplate.filter((f) => f.active);
function buildInitialValues() {
const vals = {};
for (const f of activeFields) {
vals[f.key] = animal?.subject_info?.[f.fieldId] ?? '';
}
return vals;
}
const [values, setValues] = useState(buildInitialValues);
const [apiError, setApiError] = useState(null);
const [loading, setLoading] = useState(false);
const [savingTagFor, setSavingTagFor] = useState(null);
useEffect(() => {
setValues(buildInitialValues());
setApiError(null);
}, [animal, subjectTemplate]);
const set = (key) => (e) => setValues((v) => ({ ...v, [key]: e.target.value }));
async function saveTag(field) {
const value = (values[field.key] ?? '').trim();
if (!value || !experimentId || !onTemplateUpdate) return;
setSavingTagFor(field.fieldId);
try {
const updatedTemplate = subjectTemplate.map((f) =>
f.fieldId === field.fieldId
? { ...f, tags: [...(f.tags ?? []), value] }
: f
);
await experimentsApi.updateSubjectTemplate(experimentId, updatedTemplate);
onTemplateUpdate(updatedTemplate);
} catch (_) {
} finally {
setSavingTagFor(null);
}
}
async function handleSubmit(e) {
e.preventDefault();
setLoading(true);
setApiError(null);
try {
// Preserve any fields not in the current active template
const existing = animal?.subject_info ?? {};
const updates = {};
for (const f of activeFields) {
updates[f.fieldId] = values[f.key] || null;
}
const merged = { ...existing, ...updates };
const updated = await animalsApi.update(animal.id, { subject_info: merged });
onSaved(updated);
} catch (err) {
setApiError(err);
} finally {
setLoading(false);
}
}
if (activeFields.length === 0) {
return (
<div className="text-center py-6">
<p className="text-sm text-gray-400 italic">No subject info fields configured for this experiment.</p>
<p className="text-xs text-gray-400 mt-1">Use "Subject Template" on the experiment page to add fields.</p>
<div className="flex justify-end mt-4">
<Button variant="secondary" type="button" onClick={onCancel}>Close</Button>
</div>
</div>
);
}
return (
<form onSubmit={handleSubmit} noValidate>
{apiError && (
<Alert type="error" message={apiError.message} details={apiError.details} onDismiss={() => setApiError(null)} />
)}
<div className="grid grid-cols-12 gap-x-3 gap-y-2 mb-4">
{activeFields.map((field) => {
const isTextarea = field.type === 'textarea';
const span = isTextarea ? 12 : widthToSpan(field.width ?? 100);
const fieldTags = field.tags ?? [];
const currentValue = (values[field.key] ?? '').trim();
const canSaveTag = experimentId && onTemplateUpdate && currentValue && !fieldTags.includes(currentValue) && !field.tagsDisabled;
return (
<div key={field.fieldId} className={`col-span-${span} flex items-start gap-2`}>
<label className="text-xs font-medium text-gray-500 whitespace-nowrap w-28 shrink-0 text-right pt-1.5">{field.label}</label>
<div className="flex-1 min-w-0">
{isTextarea ? (
<Textarea name={field.key} value={values[field.key] ?? ''} onChange={set(field.key)} disabled={loading} />
) : (
<Input name={field.key} value={values[field.key] ?? ''} onChange={set(field.key)} disabled={loading} />
)}
{(fieldTags.length > 0 || canSaveTag) && (
<div className="flex flex-wrap items-center gap-1.5 mt-1">
{fieldTags.map((tag) => (
<button key={tag} type="button" title={`Quick-fill: ${tag}`}
onClick={() => setValues((v) => ({ ...v, [field.key]: tag }))}
className="inline-flex items-center px-2 py-0.5 rounded-full bg-indigo-50 border border-indigo-200 text-indigo-700 text-xs font-medium hover:bg-indigo-100 transition-colors">
{tag}
</button>
))}
{canSaveTag && (
<button type="button" onClick={() => saveTag(field)} disabled={savingTagFor === field.fieldId}
title="Save current value as a quick-fill tag"
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-dashed border-indigo-300 text-indigo-500 text-xs hover:border-indigo-500 hover:text-indigo-700 transition-colors disabled:opacity-50">
{savingTagFor === field.fieldId ? '…' : '+ Save as tag'}
</button>
)}
</div>
)}
</div>
</div>
);
})}
</div>
<div className="flex justify-end gap-3 pt-2 border-t border-gray-100">
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>Cancel</Button>
<Button type="submit" loading={loading}>Save</Button>
</div>
</form>
);
}
@@ -0,0 +1,59 @@
import React from 'react';
import { groupLines, groupVisibilityState } from '../lib/crossSubjectChart';
// Presentational panel listing subjects grouped by their group, with per-subject
// and per-group visibility checkboxes. All state lives in the parent; this component
// only renders and calls handlers.
export function SubjectSidebar({
lines, hiddenSubjects, onToggleSubject, onToggleGroup, onShowAll, onHighlight,
}) {
const groups = groupLines(lines);
const anyHidden = lines.some((l) => hiddenSubjects.has(l.id));
return (
<div className="w-[210px] shrink-0 border-l border-gray-100 pl-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Subjects</span>
{anyHidden && (
<button type="button" onClick={onShowAll} aria-label="Show all subjects"
className="text-xs text-indigo-500 hover:text-indigo-700">Show all</button>
)}
</div>
<div className="space-y-3 max-h-[320px] overflow-y-auto pr-1">
{groups.map(({ group, subjects }) => {
const ids = subjects.map((s) => s.id);
const state = groupVisibilityState(ids, hiddenSubjects);
return (
<div key={group}>
<label className="flex items-center gap-1.5 text-xs font-medium text-gray-600 cursor-pointer">
<input type="checkbox"
checked={state === 'all'}
ref={(el) => { if (el) el.indeterminate = state === 'some'; }}
onChange={() => onToggleGroup(ids, state === 'all')}
className="accent-indigo-500" />
<span className="truncate">{group}</span>
<span className="text-gray-400">({subjects.length})</span>
</label>
<div className="mt-1 space-y-0.5 pl-1">
{subjects.map((s) => (
<label key={s.id}
onMouseEnter={() => onHighlight(s.id)}
onMouseLeave={() => onHighlight(null)}
className="flex items-center gap-1.5 text-xs text-gray-600 cursor-pointer hover:bg-gray-50 rounded px-1 py-0.5">
<input type="checkbox"
checked={!hiddenSubjects.has(s.id)}
onChange={() => onToggleSubject(s.id)}
className="accent-indigo-500" />
<span className="inline-block w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: s.color }} />
<span className="truncate">{s.name}</span>
</label>
))}
</div>
</div>
);
})}
</div>
</div>
);
}
+465
View File
@@ -0,0 +1,465 @@
import React, { useState, useEffect, useRef } from 'react';
import { experimentsApi } from '../api/client';
import Button from './ui/Button';
import Alert from './ui/Alert';
// ── helpers ────────────────────────────────────────────────────────────────────
function toKey(label) {
return label.toLowerCase().trim()
.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 64);
}
// ── Tag chip ───────────────────────────────────────────────────────────────────
function TagChip({ value, onRemove }) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-indigo-50 border border-indigo-200 text-indigo-700 text-xs font-medium">
{value}
<button
type="button"
aria-label={`Remove tag ${value}`}
onClick={onRemove}
className="text-indigo-400 hover:text-indigo-700 leading-none"
></button>
</span>
);
}
// ── Inline tag editor (expanded panel below a field row) ──────────────────────
function TagPanel({ field, onChange, onAddTag, onRemoveTag }) {
const [draft, setDraft] = useState('');
const [err, setErr] = useState('');
const disabled = !!field.tagsDisabled;
function commit() {
const v = draft.trim();
if (!v) { setErr('Tag cannot be empty'); return; }
if (v.length > 512) { setErr('Tag must be ≤512 characters'); return; }
if ((field.tags ?? []).includes(v)) { setErr('Tag already exists'); return; }
setErr('');
onAddTag(field.fieldId, v);
setDraft('');
}
return (
<div className="px-4 pb-3 pt-2 bg-indigo-50/40 border-t border-indigo-100 rounded-b-lg">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-semibold text-indigo-600 uppercase tracking-wide">Quick-fill tags</p>
<label className="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer select-none">
<input
type="checkbox"
checked={disabled}
onChange={(e) => onChange({ ...field, tagsDisabled: e.target.checked })}
className="w-3.5 h-3.5 accent-indigo-600"
/>
Disable tagging
</label>
</div>
{!disabled && (
<>
<div className="flex flex-wrap gap-1.5 mb-2 min-h-[24px]">
{(field.tags ?? []).length === 0 && (
<span className="text-xs text-gray-400 italic">No tags yet.</span>
)}
{(field.tags ?? []).map((t) => (
<TagChip key={t} value={t} onRemove={() => onRemoveTag(field.fieldId, t)} />
))}
</div>
<div className="flex gap-2 items-start">
<div className="flex-1">
<input
aria-label={`Add tag for ${field.label}`}
className={`w-full text-sm rounded border px-2 py-1 focus:outline-none focus:ring-2 focus:ring-indigo-400 ${err ? 'border-red-400' : 'border-gray-300'}`}
placeholder="Type a tag value…"
value={draft}
onChange={(e) => { setDraft(e.target.value); setErr(''); }}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } }}
/>
{err && <p className="text-xs text-red-500 mt-0.5">{err}</p>}
</div>
<Button size="sm" type="button" onClick={commit}>+ Add</Button>
</div>
</>
)}
{disabled && (
<p className="text-xs text-gray-400 italic">Tagging is disabled for this field. Existing tags are preserved.</p>
)}
</div>
);
}
// ── Single field row (draggable) ───────────────────────────────────────────────
function FieldRow({
field, index, total,
onChange, onToggle, onRemove, onAddTag, onRemoveTag,
allKeys,
// drag props
onDragStart, onDragEnter, onDragEnd,
isDragOver,
}) {
const [labelDraft, setLabelDraft] = useState(field.label);
const [labelError, setLabelError] = useState('');
const [showTags, setShowTags] = useState(false);
useEffect(() => { setLabelDraft(field.label); }, [field.label]);
function commitLabel() {
const trimmed = labelDraft.trim();
if (!trimmed) { setLabelError('Label cannot be empty'); return; }
if (trimmed.length > 128) { setLabelError('Label must be ≤128 characters'); return; }
if (!field.builtin) {
const newKey = toKey(trimmed);
if (!newKey) { setLabelError('Label must contain at least one alphanumeric character'); return; }
if (allKeys.find((k) => k !== field.key && k === newKey)) {
setLabelError('A field with this name already exists'); return;
}
}
setLabelError('');
onChange({ ...field, label: trimmed, key: field.builtin ? field.key : toKey(trimmed) });
}
const tagCount = (field.tags ?? []).length;
return (
<div
className={`rounded-lg border transition-all ${isDragOver ? 'border-blue-400 shadow-md scale-[1.01]' : 'border-gray-200'} ${field.active ? 'bg-white' : 'bg-gray-50 opacity-60'}`}
draggable
onDragStart={() => onDragStart(index)}
onDragEnter={() => onDragEnter(index)}
onDragEnd={onDragEnd}
onDragOver={(e) => e.preventDefault()}
>
{/* Main row */}
<div className="flex items-center gap-3 px-3 py-2.5">
{/* Drag handle */}
<span
className="text-gray-300 hover:text-gray-500 cursor-grab active:cursor-grabbing select-none text-lg leading-none"
title="Drag to reorder"
aria-label="Drag handle"
></span>
{/* Label input */}
<div className="flex-1 min-w-0">
<input
aria-label={`Field label for ${field.key}`}
className={`w-full text-sm rounded border px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 ${labelError ? 'border-red-400' : 'border-gray-300'} ${!field.active ? 'bg-gray-100' : 'bg-white'}`}
value={labelDraft}
disabled={!field.active}
onChange={(e) => { setLabelDraft(e.target.value); setLabelError(''); }}
onBlur={commitLabel}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitLabel(); } }}
/>
{labelError && <p className="text-xs text-red-500 mt-0.5">{labelError}</p>}
<p className="text-xs text-gray-400 mt-0.5 font-mono">
key: {field.key}
{field.fieldId && (
<span className="ml-2 text-gray-300" title={`Stable field ID: ${field.fieldId}`}>
· id: {field.fieldId.slice(0, 8)}
</span>
)}
</p>
</div>
{/* Type selector */}
<select
aria-label={`Field type for ${field.key}`}
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
value={field.type}
disabled={!field.active}
onChange={(e) => onChange({ ...field, type: e.target.value })}
>
<option value="text">Short text</option>
<option value="textarea">Long text</option>
</select>
{/* Width (text fields only) */}
{field.type === 'text' && (
<select
aria-label={`Width for ${field.key}`}
title="Input width in the entry form"
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
value={field.width ?? 100}
disabled={!field.active}
onChange={(e) => onChange({ ...field, width: Number(e.target.value) })}
>
<option value={100}>Full</option>
<option value={75}>3/4</option>
<option value={67}>2/3</option>
<option value={50}>1/2</option>
<option value={42}>5/12</option>
<option value={33}>1/3</option>
<option value={25}>1/4</option>
<option value={17}>1/6</option>
<option value={8}>1/12</option>
</select>
)}
{/* Abbreviation */}
<input
aria-label={`Abbreviation for ${field.key}`}
className="text-xs border border-gray-200 rounded px-2 py-1 w-20 focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
placeholder="Abbr"
title="Column abbreviation (≤10 chars)"
maxLength={10}
value={field.abbr ?? ''}
disabled={!field.active}
onChange={(e) => onChange({ ...field, abbr: e.target.value })}
/>
{/* Unit */}
<input
aria-label={`Unit for ${field.key}`}
className="text-xs border border-gray-200 rounded px-2 py-1 w-20 focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
placeholder="Unit"
title="Measurement unit (≤10 chars)"
maxLength={10}
value={field.unit ?? ''}
disabled={!field.active}
onChange={(e) => onChange({ ...field, unit: e.target.value })}
/>
{/* Tags toggle */}
<button
type="button"
title={showTags ? 'Hide tags' : 'Manage quick-fill tags'}
aria-label={`Manage tags for ${field.label}`}
onClick={() => setShowTags((v) => !v)}
className={`relative text-sm px-2 py-1 rounded border transition-colors ${
showTags
? 'border-indigo-400 bg-indigo-50 text-indigo-700'
: 'border-gray-200 text-gray-500 hover:bg-indigo-50 hover:border-indigo-300 hover:text-indigo-700'
}`}
>
🏷
{tagCount > 0 && (
<span className="absolute -top-1.5 -right-1.5 bg-indigo-600 text-white text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center leading-none">
{tagCount > 9 ? '9+' : tagCount}
</span>
)}
</button>
{/* Hide / Show */}
<button
type="button"
title={field.active ? 'Hide field' : 'Show field'}
aria-label={field.active ? `Hide ${field.label}` : `Show ${field.label}`}
onClick={() => onToggle(field.fieldId)}
className={`text-sm px-2 py-1 rounded border transition-colors ${
field.active
? 'border-gray-200 text-gray-500 hover:bg-yellow-50 hover:border-yellow-300 hover:text-yellow-700'
: 'border-green-200 text-green-600 hover:bg-green-50'
}`}
>
{field.active ? 'Hide' : 'Show'}
</button>
{/* Remove (custom fields only) */}
{!field.builtin && (
<button
type="button"
title="Remove field permanently"
aria-label={`Remove ${field.label}`}
onClick={() => onRemove(field.fieldId)}
className="text-sm px-2 py-1 rounded border border-red-200 text-red-500 hover:bg-red-50 transition-colors"
></button>
)}
</div>
{/* Tag panel (collapsible) */}
{showTags && (
<TagPanel field={field} onChange={onChange} onAddTag={onAddTag} onRemoveTag={onRemoveTag} />
)}
</div>
);
}
// ── Main TemplateEditor ────────────────────────────────────────────────────────
export default function TemplateEditor({ experimentId, onClose, loadFn, saveFn }) {
const [fields, setFields] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(false);
// Add-field form
const [newLabel, setNewLabel] = useState('');
const [newType, setNewType] = useState('text');
const [addError, setAddError] = useState('');
// Drag state
const dragIndex = useRef(null);
const [dragOverIndex, setDragOverIndex] = useState(null);
useEffect(() => {
const fn = loadFn ?? (() => experimentsApi.getTemplate(experimentId));
fn().then(setFields).catch((e) => setError(e.message)).finally(() => setLoading(false));
}, [experimentId]);
// ── field mutation helpers ─────────────────────────────────────────────────
function updateField(updated) {
setFields((prev) => prev.map((f) => f.fieldId === updated.fieldId ? updated : f));
}
function toggleField(fieldId) {
setFields((prev) => prev.map((f) => f.fieldId === fieldId ? { ...f, active: !f.active } : f));
}
function removeField(fieldId) {
setFields((prev) => prev.filter((f) => f.fieldId !== fieldId));
}
// ── tag helpers ────────────────────────────────────────────────────────────
function addTag(fieldId, value) {
setFields((prev) => prev.map((f) =>
f.fieldId === fieldId
? { ...f, tags: [...(f.tags ?? []), value] }
: f
));
}
function removeTag(fieldId, value) {
setFields((prev) => prev.map((f) =>
f.fieldId === fieldId
? { ...f, tags: (f.tags ?? []).filter((t) => t !== value) }
: f
));
}
// ── drag handlers ──────────────────────────────────────────────────────────
function handleDragStart(index) {
dragIndex.current = index;
}
function handleDragEnter(index) {
setDragOverIndex(index);
}
function handleDragEnd() {
const from = dragIndex.current;
const to = dragOverIndex;
if (from !== null && to !== null && from !== to) {
setFields((prev) => {
const next = [...prev];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next;
});
}
dragIndex.current = null;
setDragOverIndex(null);
}
// ── add new field ──────────────────────────────────────────────────────────
function addField() {
const trimmed = newLabel.trim();
if (!trimmed) { setAddError('Field name is required'); return; }
const key = toKey(trimmed);
if (!key) { setAddError('Name must contain at least one alphanumeric character'); return; }
if (fields.some((f) => f.key === key)) { setAddError(`A field with key "${key}" already exists`); return; }
setAddError('');
const fieldId = crypto.randomUUID();
setFields((prev) => [...prev, { fieldId, key, label: trimmed, type: newType, builtin: false, active: true, tags: [] }]);
setNewLabel('');
setNewType('text');
}
// ── save ───────────────────────────────────────────────────────────────────
async function save() {
setSaving(true);
setError(null);
try {
const fn = saveFn ?? ((f) => experimentsApi.updateTemplate(experimentId, f));
await fn(fields);
setSuccess(true);
setTimeout(() => { setSuccess(false); onClose(fields); }, 700);
} catch (e) {
setError(e.message);
} finally {
setSaving(false);
}
}
const allKeys = fields.map((f) => f.key);
return (
<div className="space-y-4">
{loading && <p className="text-sm text-gray-400">Loading template</p>}
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
{success && <Alert type="success" message="Template saved!" />}
{!loading && (
<>
{/* Field list */}
<div className="space-y-2">
{fields.length === 0 && (
<p className="text-sm text-gray-400 italic text-center py-4">No fields yet. Add one below.</p>
)}
{fields.map((field, index) => (
<FieldRow
key={field.fieldId}
field={field}
index={index}
total={fields.length}
onChange={updateField}
onToggle={toggleField}
onRemove={removeField}
onAddTag={addTag}
onRemoveTag={removeTag}
allKeys={allKeys}
onDragStart={handleDragStart}
onDragEnter={handleDragEnter}
onDragEnd={handleDragEnd}
isDragOver={dragOverIndex === index}
/>
))}
</div>
{/* Add new field */}
<div className="border-t pt-4">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Add New Field</p>
<div className="flex gap-2 items-start">
<div className="flex-1">
<input
aria-label="New field name"
className={`w-full text-sm rounded border px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 ${addError ? 'border-red-400' : 'border-gray-300'}`}
placeholder="Field name, e.g. Blood Pressure"
value={newLabel}
onChange={(e) => { setNewLabel(e.target.value); setAddError(''); }}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addField(); } }}
/>
{addError && <p className="text-xs text-red-500 mt-0.5">{addError}</p>}
</div>
<select
aria-label="New field type"
className="text-sm border border-gray-300 rounded px-2 py-2 bg-white focus:outline-none focus:ring-1 focus:ring-blue-400"
value={newType}
onChange={(e) => setNewType(e.target.value)}
>
<option value="text">Short text</option>
<option value="textarea">Long text</option>
</select>
<Button size="sm" type="button" onClick={addField}>+ Add</Button>
</div>
</div>
<p className="text-xs text-gray-400">
Drag <span className="font-mono"></span> to reorder · <strong>Hide</strong> removes a builtin field from forms while preserving its data · <strong>🏷</strong> manages quick-fill tags
</p>
{/* Actions */}
<div className="flex justify-end gap-3 border-t pt-4">
<Button variant="secondary" onClick={() => onClose(null)} disabled={saving}>Cancel</Button>
<Button onClick={save} loading={saving}>Save Template</Button>
</div>
</>
)}
</div>
);
}
+25 -4
View File
@@ -1,16 +1,37 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import Modal from './Modal';
import Button from './Button';
export default function ConfirmDialog({ isOpen, onClose, onConfirm, title, message, loading }) {
export default function ConfirmDialog({ isOpen, onClose, onConfirm, title, message, loading, confirmWord }) {
const [typed, setTyped] = useState('');
useEffect(() => { if (!isOpen) setTyped(''); }, [isOpen]);
const ready = confirmWord ? typed === confirmWord : true;
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
<p className="text-sm text-gray-600 mb-6">{message}</p>
<p className="text-sm text-gray-600 mb-4">{message}</p>
{confirmWord && (
<div className="mb-5">
<label className="block text-xs text-gray-500 mb-1">
Type <span className="font-mono font-semibold text-gray-700">{confirmWord}</span> to confirm
</label>
<input
type="text"
value={typed}
onChange={(e) => setTyped(e.target.value)}
autoFocus
className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-300 focus:border-red-400"
placeholder={confirmWord}
/>
</div>
)}
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button variant="danger" onClick={onConfirm} loading={loading}>
<Button variant="danger" onClick={onConfirm} loading={loading} disabled={!ready}>
Delete
</Button>
</div>
+1 -1
View File
@@ -10,7 +10,7 @@ export default function Modal({ isOpen, onClose, title, children, size = 'md' })
if (!isOpen) return null;
const widths = { sm: 'max-w-md', md: 'max-w-lg', lg: 'max-w-2xl', xl: 'max-w-4xl' };
const widths = { sm: 'max-w-md', md: 'max-w-lg', lg: 'max-w-2xl', xl: 'max-w-4xl', full: 'w-[75vw] max-w-none' };
return (
<div
+111
View File
@@ -0,0 +1,111 @@
// Pure helpers for the cross-subject metrics plot interactions. No React, no I/O.
// Sort a Recharts tooltip payload by numeric value descending, dropping entries
// whose value is null/undefined. Returns a NEW array (does not mutate input).
export function sortPayloadByValueDesc(payload) {
if (!Array.isArray(payload)) return [];
return payload
.filter((p) => p && p.value != null)
.sort((a, b) => b.value - a.value);
}
// Toggle an id's membership in a Set, returning a NEW Set.
export function toggleInSet(set, id) {
const next = new Set(set);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
}
// Add (hidden=true) or remove (hidden=false) a list of ids from a hidden-set.
// Returns a NEW Set.
export function setIdsHidden(set, ids, hidden) {
const next = new Set(set);
for (const id of ids) {
if (hidden) next.add(id);
else next.delete(id);
}
return next;
}
// Tri-state for a group checkbox given which subject ids are hidden.
// 'all' = all visible, 'none' = all hidden, 'some' = mixed.
export function groupVisibilityState(subjectIds, hiddenSet) {
if (subjectIds.length === 0) return 'none';
const hiddenCount = subjectIds.filter((id) => hiddenSet.has(id)).length;
if (hiddenCount === 0) return 'all';
if (hiddenCount === subjectIds.length) return 'none';
return 'some';
}
// Group chart "lines" by their .group field, preserving first-seen group order
// and line order within each group. Returns [{ group, subjects: [line...] }].
export function groupLines(lines) {
const order = [];
const byGroup = new Map();
for (const line of lines) {
const g = line.group ?? '—';
if (!byGroup.has(g)) { byGroup.set(g, []); order.push(g); }
byGroup.get(g).push(line);
}
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),
};
}
+265
View File
@@ -0,0 +1,265 @@
/**
* CSV Analysis Library
*
* Method summary:
*
* 1. PARSING
* - parseCSVHeaders(text): reads only the first line, splits on comma
* (handles double-quoted fields with embedded commas).
* - parseCSV(text): full parse into array-of-objects. Each row is
* { [headerName]: value, ... }. Empty trailing fields are preserved as ''.
*
* 2. UNIQUE VALUES
* - getUniqueNoteValues(rows, noteCol): scans each row's noteCol,
* trims whitespace, ignores empty strings, returns sorted unique values.
*
* 3. ANALYSIS (runAnalysis)
* Input:
* rows — full parsed rows
* timestampCol — column name to sort by (numeric sort; falls back to row order)
* noteCol — column containing note values
* classifications — plain object: { noteValue: categoryName }
* unclassified notes are included in the sequence
* but excluded from category counts and run stats.
*
* Steps:
* a) Filter to rows where noteCol is non-empty.
* b) Sort by timestampCol (parseFloat; NaN values go to the end).
* c) Map each row to { note, category, timestamp }.
* d) CATEGORY COUNTS: for each category, count total occurrences
* and per-note-value breakdown.
* e) RUN-LENGTH ENCODING (consecutive stat):
* Walk the category sequence. Each time the category changes,
* start a new run. Uncategorised notes are skipped (do not break
* or continue a run). This gives an ordered list of
* { category, length } objects representing every streak.
* f) CONSECUTIVE STATS per category:
* From the runs list, group run lengths per category and compute
* totalRuns, maxRun, avgRun, and the full runLengths array.
*
* Output: { totalNoteRows, sequence, categoryCounts, runs, consecutiveStats }
*/
// ── CSV parsing ────────────────────────────────────────────────────────────────
function splitCSVLine(line) {
const fields = [];
let field = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') {
if (inQuotes && line[i + 1] === '"') { field += '"'; i++; }
else { inQuotes = !inQuotes; }
} else if (ch === ',' && !inQuotes) {
fields.push(field);
field = '';
} else {
field += ch;
}
}
fields.push(field);
return fields;
}
/** Return column names from the first line of a CSV string. */
export function parseCSVHeaders(text) {
const firstLine = text.split(/\r?\n/)[0];
return splitCSVLine(firstLine);
}
/** Parse the full CSV into { headers, rows }. */
export function parseCSV(text) {
const lines = text.split(/\r?\n/);
const headers = splitCSVLine(lines[0]);
const rows = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (!line.trim()) continue;
const values = splitCSVLine(line);
const row = {};
for (let j = 0; j < headers.length; j++) {
row[headers[j]] = values[j] ?? '';
}
rows.push(row);
}
return { headers, rows };
}
// ── Unique note values ─────────────────────────────────────────────────────────
/** Return sorted unique non-empty values found in noteCol. */
export function getUniqueNoteValues(rows, noteCol) {
const seen = new Set();
for (const row of rows) {
const v = (row[noteCol] ?? '').trim();
if (v) seen.add(v);
}
return [...seen].sort();
}
// ── Frame gap detection ────────────────────────────────────────────────────────
/**
* Check whether a numeric column is strictly consecutive (no gaps, no duplicates).
* Returns { consecutive: bool, gaps: [{index, expected, actual, jump}], duplicates: [{index, value}] }
* Only examines rows where the column value parses as an integer.
*/
export function checkFrameConsecutive(rows, frameCol) {
const entries = [];
for (let i = 0; i < rows.length; i++) {
const v = rows[i][frameCol];
if (v == null || String(v).trim() === '') continue;
const n = parseInt(v, 10);
if (isNaN(n)) continue;
entries.push({ rowIndex: i, value: n });
}
const gaps = [];
const duplicates = [];
for (let i = 1; i < entries.length; i++) {
const diff = entries[i].value - entries[i - 1].value;
if (diff === 0) {
duplicates.push({ rowIndex: entries[i].rowIndex, value: entries[i].value });
} else if (diff !== 1) {
gaps.push({
rowIndex: entries[i].rowIndex,
expected: entries[i - 1].value + 1,
actual: entries[i].value,
jump: diff,
});
}
}
return { consecutive: gaps.length === 0 && duplicates.length === 0, gaps, duplicates, total: entries.length };
}
// ── Analysis ───────────────────────────────────────────────────────────────────
/**
* Run the full analysis.
*
* @param {object[]} rows - Parsed CSV rows
* @param {string} timestampCol - Column to sort by
* @param {string} noteCol - Column with note values
* @param {object} classifications - { noteValue: categoryName }
* @returns {object} Analysis result
*/
export function runAnalysis(rows, timestampCol, noteCol, classifications) {
// Compute session end from ALL rows (not just note rows)
let sessionEndTs = null;
for (const r of rows) {
const t = parseFloat(r[timestampCol]);
if (!isNaN(t) && (sessionEndTs === null || t > sessionEndTs)) sessionEndTs = t;
}
// a) Filter non-empty notes
const noteRows = rows.filter(r => (r[noteCol] ?? '').trim() !== '');
// b) Sort by timestamp (numeric); preserve original order for equal/NaN timestamps
noteRows.sort((a, b) => {
const ta = parseFloat(a[timestampCol]);
const tb = parseFloat(b[timestampCol]);
if (isNaN(ta) && isNaN(tb)) return 0;
if (isNaN(ta)) return 1;
if (isNaN(tb)) return -1;
return ta - tb;
});
// c) Build sequence
const sequence = noteRows.map(r => ({
note: r[noteCol].trim(),
category: classifications[r[noteCol].trim()] ?? null,
timestamp: r[timestampCol],
}));
// d) Category counts
const categoryCounts = {};
for (const { note, category } of sequence) {
if (!category) continue;
if (!categoryCounts[category]) categoryCounts[category] = { total: 0, byNote: {} };
categoryCounts[category].total++;
categoryCounts[category].byNote[note] = (categoryCounts[category].byNote[note] || 0) + 1;
}
// e) Run-length encoding (skip uncategorised entries — they neither break nor extend a run)
const runs = [];
for (const { category } of sequence) {
if (!category) continue;
if (runs.length === 0 || runs[runs.length - 1].category !== category) {
runs.push({ category, length: 1 });
} else {
runs[runs.length - 1].length++;
}
}
// f) Consecutive stats per category
const consecutiveStats = {};
for (const { category, length } of runs) {
if (!consecutiveStats[category]) {
consecutiveStats[category] = { runLengths: [], totalRuns: 0, maxRun: 0, avgRun: 0 };
}
const s = consecutiveStats[category];
s.runLengths.push(length);
s.totalRuns++;
if (length > s.maxRun) s.maxRun = length;
}
for (const cat of Object.keys(consecutiveStats)) {
const s = consecutiveStats[cat];
s.avgRun = s.runLengths.reduce((a, b) => a + b, 0) / s.runLengths.length;
}
return {
totalNoteRows: noteRows.length,
sessionEndTs,
sequence,
categoryCounts,
runs,
consecutiveStats,
};
}
// ── Numeric extraction (for numerical buckets) ────────────────────────────────
/**
* Extract the first signed decimal from a string. Returns null if none found.
* Examples: "L25.5" -> 25.5, "R-12" -> -12, "abc" -> null
*/
export function extractNumber(str) {
if (str == null) return null;
const m = String(str).match(/-?\d+(\.\d+)?/);
return m ? parseFloat(m[0]) : null;
}
/**
* Build a numeric-vs-x series for a numerical bucket.
*
* @param {object[]} rows - Parsed CSV rows
* @param {string} xColName - Column to plot on the x-axis (frame col, or timestamp as fallback)
* @param {string} noteCol - Column containing note values
* @param {object} bucket - { name, type:'numerical', notes: string[] }
* @returns {{points: {x:number,value:number,raw:string}[], avg:number|null, skipped:number, total:number, xLabel:string}}
*/
export function computeNumericSeries(rows, xColName, noteCol, bucket) {
const noteSet = new Set(bucket.notes);
const points = [];
let skipped = 0;
let total = 0;
for (const r of rows) {
const note = String(r[noteCol] ?? '').trim();
if (!noteSet.has(note)) continue;
total++;
const value = extractNumber(note);
const x = parseFloat(r[xColName]);
if (value == null || Number.isNaN(x)) { skipped++; continue; }
points.push({ x, value, raw: note });
}
points.sort((a, b) => a.x - b.x);
const avg = points.length === 0
? null
: points.reduce((s, p) => s + p.value, 0) / points.length;
return { points, avg, skipped, total, xLabel: xColName };
}
+134
View File
@@ -0,0 +1,134 @@
// Pure helpers for exporting daily-parameter data as a date×subject CSV matrix.
// No React, no I/O — mirrors the crossSubjectChart.js pure-helper pattern.
// Read a single parameter's raw value from one daily status.
// Returns null when the value is absent. Note: 0 and '' are returned as-is
// (real values); only genuinely-missing lookups become null.
export function extractValue(status, param) {
if (!status || !param) return null;
switch (param.kind) {
case 'total':
return status.analysis_summary?.total ?? null;
case 'success_rate':
return status.analysis_summary?.success_rate ?? null;
case 'category':
return status.analysis_summary?.counts?.[param.category] ?? null;
case 'builtin':
return status[param.statusKey] ?? null;
case 'custom':
return status.custom_fields?.[param.fieldId] ?? null;
default:
return null;
}
}
// Build the grouped, ordered list of exportable parameters.
// Session metrics (total, success_rate, then each dynamic count category found
// in the data) come first; then every field defined in the daily template,
// including inactive ones (historical data may exist).
export function listExportableParams(dailyTemplate, statuses) {
const params = [
{ id: '__total__', label: 'Total attempts', group: 'metrics', kind: 'total' },
{ id: '__success_rate__', label: 'Success rate', group: 'metrics', kind: 'success_rate' },
];
const cats = new Set();
for (const s of statuses ?? []) {
const counts = s?.analysis_summary?.counts;
if (counts && typeof counts === 'object') {
for (const k of Object.keys(counts)) cats.add(k);
}
}
for (const cat of [...cats].sort()) {
params.push({ id: `__cat__${cat}`, label: cat, group: 'metrics', kind: 'category', category: cat });
}
for (const f of dailyTemplate ?? []) {
if (f.builtin) {
params.push({ id: `b:${f.fieldId}`, label: f.label, group: 'daily', kind: 'builtin', statusKey: f.key });
} else {
params.push({ id: `c:${f.fieldId}`, label: f.label, group: 'daily', kind: 'custom', fieldId: f.fieldId });
}
}
return params;
}
// A cell has a value unless it is null, undefined, or the empty string.
// (0 and false are real values.)
function hasValue(v) {
return v !== null && v !== undefined && v !== '';
}
function dateKey(date) {
return String(date).slice(0, 10); // ISO datetime or date → YYYY-MM-DD
}
// Pivot statuses into { columns, rows }.
// columns: [{ id, header }] — all animals, ordered by name, headers
// disambiguated with animal_id_string on duplicate names.
// rows: [{ date, values }] — one per date that has ≥1 value; values are
// aligned to columns, blank cells are '' (empty string).
export function buildMatrix(statuses, animals, param) {
const sortedAnimals = [...(animals ?? [])].sort((a, b) =>
String(a.animal_name ?? '').localeCompare(String(b.animal_name ?? '')),
);
const nameCounts = new Map();
for (const a of sortedAnimals) {
const n = a.animal_name ?? '';
nameCounts.set(n, (nameCounts.get(n) ?? 0) + 1);
}
const columns = sortedAnimals.map((a) => {
const name = a.animal_name ?? '';
const header = nameCounts.get(name) > 1 ? `${name} (${a.animal_id_string ?? ''})` : name;
return { id: a.id, header };
});
const colIndex = new Map(columns.map((c, i) => [c.id, i]));
// cells: dateKey -> Map(animalId -> value); first status per (date, animal) wins.
const cells = new Map();
const datesWithData = new Set();
const ordered = [...(statuses ?? [])].sort((a, b) => dateKey(a.date).localeCompare(dateKey(b.date)));
for (const s of ordered) {
if (!colIndex.has(s.animal_id)) continue;
const dk = dateKey(s.date);
if (!cells.has(dk)) cells.set(dk, new Map());
const byAnimal = cells.get(dk);
if (byAnimal.has(s.animal_id)) continue; // first wins
const v = extractValue(s, param);
byAnimal.set(s.animal_id, v);
if (hasValue(v)) datesWithData.add(dk);
}
const rows = [...datesWithData].sort().map((dk) => {
const byAnimal = cells.get(dk);
const values = columns.map((c) => {
const v = byAnimal.has(c.id) ? byAnimal.get(c.id) : null;
return hasValue(v) ? v : '';
});
return { date: dk, values };
});
return { columns, rows };
}
function escapeCsvCell(value) {
const s = value === null || value === undefined ? '' : String(value);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
// Serialize a matrix to an RFC-4180-ish CSV string (LF line endings).
// First column is the date; remaining columns follow matrix.columns order.
export function toCSV(matrix) {
const header = ['Date', ...matrix.columns.map((c) => c.header)].map(escapeCsvCell).join(',');
const lines = matrix.rows.map((r) => [r.date, ...r.values].map(escapeCsvCell).join(','));
return [header, ...lines].join('\n');
}
// Build a download filename from the experiment title and parameter label.
export function csvFilename(title, label) {
const slug = (s) => String(s ?? '').replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '');
const base = [slug(title), slug(label)].filter(Boolean).join('-');
return `${base || 'export'}.csv`;
}
+422 -40
View File
@@ -1,20 +1,119 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { animalsApi, dailyStatusesApi } from '../api/client';
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useParams, useNavigate, Link, useLocation } from 'react-router-dom';
import { animalsApi, dailyStatusesApi, experimentsApi } from '../api/client';
import { format } from 'date-fns';
import Button from '../components/ui/Button';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import DailyStatusForm from '../components/DailyStatusForm';
import SubjectInfoForm from '../components/SubjectInfoForm';
import Alert from '../components/ui/Alert';
import AuditLogSection from '../components/AuditLogSection';
import { SubjectAnalysisCharts } from '../components/AnalysisCharts';
/** Read the value for a template field from a status record.
* Custom fields are keyed by fieldId, not by the display key. */
function getFieldValue(status, field) {
if (field.builtin) return status[field.key];
return status.custom_fields?.[field.fieldId];
}
// ── Inline cell editor ─────────────────────────────────────────────────────────
function InlineCell({ statusId, field, value, currentStatus, onSaved }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState(null);
const inputRef = useRef(null);
const isTextarea = field.type === 'textarea';
function startEdit(e) {
e.preventDefault();
e.stopPropagation();
setDraft(value ?? '');
setSaveError(null);
setEditing(true);
}
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
if (inputRef.current.select) inputRef.current.select();
}
}, [editing]);
async function commit() {
setEditing(false);
const newValue = draft.trim() || null;
if (newValue === (value?.trim() || null)) return;
setSaving(true);
setSaveError(null);
try {
let body;
if (field.builtin) {
body = { [field.key]: newValue };
} else {
body = { custom_fields: { ...currentStatus.custom_fields, [field.fieldId]: newValue } };
}
const updated = await dailyStatusesApi.update(statusId, body);
onSaved(updated);
} catch (err) {
setSaveError(err.message ?? 'Save failed');
} finally {
setSaving(false);
}
}
function cancel() { setEditing(false); }
if (editing) {
const sharedProps = {
ref: inputRef,
value: draft,
onChange: (e) => setDraft(e.target.value),
onBlur: commit,
onClick: (e) => e.stopPropagation(),
onKeyDown: (e) => {
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
if (e.key === 'Enter' && !isTextarea) { e.preventDefault(); commit(); }
},
className: 'w-full border border-indigo-400 rounded px-1.5 py-0.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300 bg-white',
};
return isTextarea
? <textarea {...sharedProps} rows={3} className={sharedProps.className + ' resize-none'} />
: <input type="text" {...sharedProps} />;
}
return (
<div
onDoubleClick={startEdit}
title={editing ? '' : 'Double-click to edit'}
className={`min-h-[1.25rem] cursor-default rounded transition-colors px-0.5 -mx-0.5
${saving ? 'opacity-40' : 'hover:bg-indigo-50/60 group/ic'}`}
>
{saveError
? <span className="text-red-500 text-xs">{saveError}</span>
: value
? isTextarea
? <span className="line-clamp-2 whitespace-pre-wrap">{value}</span>
: <span className="truncate block">{value}</span>
: <span className="text-gray-200 group-hover/ic:text-gray-400 select-none text-xs">· · ·</span>
}
</div>
);
}
export default function AnimalDetail() {
const { id } = useParams();
const navigate = useNavigate();
const location = useLocation();
const [animal, setAnimal] = useState(null);
const [statuses, setStatuses] = useState([]);
const [template, setTemplate] = useState([]);
const [subjectTemplate, setSubjectTemplate] = useState([]);
const [showSubjectInfo, setShowSubjectInfo] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [successMsg, setSuccessMsg] = useState(null);
@@ -23,6 +122,40 @@ export default function AnimalDetail() {
const [editStatus, setEditStatus] = useState(null);
const [deleteStatus, setDeleteStatus] = useState(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [showDeleteSubject, setShowDeleteSubject] = useState(false);
const [deleteSubjectLoading, setDeleteSubjectLoading] = useState(false);
const [fillingGaps, setFillingGaps] = useState(false);
// Column visibility — persisted per experiment
const [hiddenCols, setHiddenCols] = useState(new Set());
const [showColPicker, setShowColPicker] = useState(false);
const colPickerRef = useRef(null);
useEffect(() => {
if (!animal) return;
const stored = localStorage.getItem(`col-vis-${animal.id}`);
setHiddenCols(stored ? new Set(JSON.parse(stored)) : new Set());
}, [animal?.id]);
function toggleCol(fieldId) {
setHiddenCols((prev) => {
const next = new Set(prev);
next.has(fieldId) ? next.delete(fieldId) : next.add(fieldId);
localStorage.setItem(`col-vis-${animal.id}`, JSON.stringify([...next]));
return next;
});
}
useEffect(() => {
if (!showColPicker) return;
function handleClick(e) {
if (colPickerRef.current && !colPickerRef.current.contains(e.target)) {
setShowColPicker(false);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [showColPicker]);
async function load() {
setLoading(true);
@@ -34,6 +167,13 @@ export default function AnimalDetail() {
]);
setAnimal(animalData);
setStatuses(statusData);
const [tmpl, subjTmpl] = await Promise.all([
experimentsApi.getTemplate(animalData.experiment_id),
experimentsApi.getSubjectTemplate(animalData.experiment_id),
]);
setTemplate(tmpl);
setSubjectTemplate(subjTmpl);
} catch (err) {
setError(err.message);
} finally {
@@ -60,6 +200,7 @@ export default function AnimalDetail() {
try {
await dailyStatusesApi.delete(deleteStatus.id);
setDeleteStatus(null);
setEditStatus(null);
load();
flash('Daily status removed.');
} catch (err) {
@@ -70,7 +211,33 @@ export default function AnimalDetail() {
}
}
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400">Loading</div>;
async function fillGaps() {
setFillingGaps(true);
try {
await Promise.all(missingDates.map((date) => dailyStatusesApi.create({ date, animal_id: id })));
load();
flash(`Created ${missingDates.length} missing entr${missingDates.length === 1 ? 'y' : 'ies'}.`);
} catch (err) {
setError(err.message);
} finally {
setFillingGaps(false);
}
}
async function handleDeleteSubject() {
setDeleteSubjectLoading(true);
try {
await animalsApi.delete(animal.id);
navigate(`/experiments/${animal.experiment_id}`, { replace: true });
} catch (err) {
setError(err.message);
setShowDeleteSubject(false);
} finally {
setDeleteSubjectLoading(false);
}
}
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400" aria-live="polite" aria-busy="true">Loading</div>;
if (error) return (
<div className="max-w-5xl mx-auto px-4 py-8">
<Alert type="error" message={error} />
@@ -78,15 +245,32 @@ export default function AnimalDetail() {
</div>
);
const activeFields = template.filter((f) => f.active);
const visibleFields = activeFields.filter((f) => !hiddenCols.has(f.fieldId));
// Dates with entries
const existingDateSet = new Set(statuses.map((s) => String(s.date).slice(0, 10)));
const missingDates = (() => {
if (statuses.length < 2) return [];
const sorted = [...existingDateSet].sort();
const missing = [];
const cur = new Date(sorted[0] + 'T12:00:00');
const end = new Date(sorted[sorted.length - 1] + 'T12:00:00');
while (cur < end) {
cur.setDate(cur.getDate() + 1);
const d = cur.toISOString().slice(0, 10);
if (!existingDateSet.has(d)) missing.push(d);
}
return missing;
})();
return (
<div className="max-w-5xl mx-auto px-4 py-8">
{/* Breadcrumb */}
<nav className="text-sm text-gray-500 mb-4">
<Link to="/" className="hover:text-blue-600">Experiments</Link>
<span className="mx-2">/</span>
<Link to={`/experiments/${animal.experiment_id}`} className="hover:text-blue-600">
Experiment
</Link>
<Link to={`/experiments/${animal.experiment_id}`} className="hover:text-blue-600">Experiment</Link>
<span className="mx-2">/</span>
<span className="text-gray-900 font-medium">{animal.animal_name}</span>
</nav>
@@ -106,61 +290,248 @@ export default function AnimalDetail() {
{successMsg && <Alert type="success" message={successMsg} />}
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
{/* Subject information card */}
{subjectTemplate.filter((f) => f.active).length > 0 && (
<div className="bg-white border border-gray-200 rounded-xl p-5 mb-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">Subject Information</h2>
<button
type="button"
onClick={() => setShowSubjectInfo(true)}
className="text-xs text-gray-400 hover:text-blue-600 transition-colors inline-flex items-center gap-1"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536M9 13l6.586-6.586a2 2 0 112.828 2.828L11.828 15.828A2 2 0 0110 16.414V19h2.586a2 2 0 001.414-.586l6.5-6.5" />
</svg>
Edit
</button>
</div>
{(() => {
const filled = subjectTemplate.filter((f) => f.active && animal.subject_info?.[f.fieldId]);
if (filled.length === 0) {
return (
<button
type="button"
onClick={() => setShowSubjectInfo(true)}
className="text-sm text-gray-400 italic hover:text-blue-600 transition-colors"
>
No information recorded yet click to add
</button>
);
}
return (
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
{filled.map((f) => (
<div key={f.fieldId} className={f.type === 'textarea' ? 'sm:col-span-2' : ''}>
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">{f.label}</dt>
<dd className="text-gray-800 mt-0.5 whitespace-pre-wrap">{animal.subject_info[f.fieldId]}</dd>
</div>
))}
</dl>
);
})()}
</div>
)}
{statuses.length === 0 ? (
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl">
<p className="text-gray-400 mb-3">No daily statuses logged yet.</p>
<Button onClick={() => setShowAddStatus(true)}>+ Log First Status</Button>
</div>
) : (
<div className="space-y-3">
{statuses.map((s) => (
<div key={s.id} className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-center justify-between mb-3">
<span className="font-semibold text-gray-900">
{format(new Date(s.date), 'MMMM d, yyyy')}
<div className="border border-gray-200 rounded-xl overflow-hidden">
{/* Column picker toolbar */}
{activeFields.length > 0 && (
<div className="flex justify-end px-3 py-1.5 border-b border-gray-100 bg-gray-50/60">
<div className="relative" ref={colPickerRef}>
<button
type="button"
onClick={() => setShowColPicker((v) => !v)}
className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-800 px-2 py-1 rounded hover:bg-gray-100 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
</svg>
Columns
{hiddenCols.size > 0 && (
<span className="ml-0.5 bg-indigo-100 text-indigo-700 rounded-full px-1.5 py-px text-[10px] font-medium leading-none">
{activeFields.length - hiddenCols.size}/{activeFields.length}
</span>
<div className="flex gap-2">
)}
</button>
{showColPicker && (
<div className="absolute right-0 top-full mt-1 z-30 bg-white border border-gray-200 rounded-lg shadow-lg p-2 min-w-[180px]">
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide px-1 mb-1">Show / hide columns</p>
{activeFields.map((f) => {
const visible = !hiddenCols.has(f.fieldId);
return (
<label key={f.fieldId} className="flex items-center gap-2 px-1 py-0.5 rounded hover:bg-gray-50 cursor-pointer select-none text-xs text-gray-700">
<input
type="checkbox"
checked={visible}
onChange={() => toggleCol(f.fieldId)}
className="w-3.5 h-3.5 accent-indigo-600"
/>
<span className="truncate" title={f.label}>{f.label}</span>
</label>
);
})}
{hiddenCols.size > 0 && (
<button
type="button"
onClick={() => {
setHiddenCols(new Set());
localStorage.removeItem(`col-vis-${animal.id}`);
}}
className="mt-1 w-full text-left text-[10px] text-indigo-500 hover:text-indigo-700 px-1 py-0.5"
>
Show all
</button>
)}
</div>
)}
</div>
</div>
)}
<div className="overflow-x-auto">
<table className="text-sm border-collapse min-w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap sticky left-0 bg-gray-50 z-10 border-r border-gray-200 min-w-[110px]">
Date
</th>
{visibleFields.map((f) => {
const header = f.abbr ? (f.unit ? `${f.abbr} (${f.unit})` : f.abbr) : (f.unit ? `${f.label} (${f.unit})` : f.label);
const tooltip = f.unit ? `${f.label} (${f.unit})` : f.label;
const minW = Math.max(80, header.length * 7.5 + 24);
return (
<th key={f.key} title={tooltip} style={{ minWidth: minW }} className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap max-w-[220px] cursor-default">
{header}
</th>
);
})}
{activeFields.length === 0 && (
<>
{['Experiment Description', 'Vitals', 'Treatment', 'Notes'].map((l) => (
<th key={l} className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap min-w-[120px]">{l}</th>
))}
</>
)}
<th className="sticky right-0 bg-gray-50 z-10 border-l border-gray-200 px-3 py-2 min-w-[96px]" />
</tr>
</thead>
<tbody>
{statuses.map((s, i) => {
const rowBg = i % 2 === 0 ? 'bg-white' : 'bg-gray-50/40';
const renderFields = activeFields.length > 0 ? visibleFields : [
{ fieldId: '__exp_desc__', key: 'experiment_description', label: 'Experiment Description', type: 'textarea', builtin: true, active: true },
{ fieldId: '__vitals__', key: 'vitals', label: 'Vitals', type: 'text', builtin: true, active: true },
{ fieldId: '__treatment__',key: 'treatment', label: 'Treatment', type: 'text', builtin: true, active: true },
{ fieldId: '__notes__', key: 'notes', label: 'Notes', type: 'textarea', builtin: true, active: true },
];
return (
<tr key={s.id} className={`border-b border-gray-100 last:border-0 ${rowBg}`}>
<td
className={`px-3 py-2 font-medium text-gray-700 whitespace-nowrap sticky left-0 z-10 border-r border-gray-100 cursor-pointer hover:text-blue-600 hover:bg-blue-50/40 transition-colors ${rowBg}`}
onClick={() => navigate(`/daily-statuses/${s.id}`)}
>
{format(new Date(String(s.date).slice(0, 10) + 'T12:00:00'), 'MMM d, yyyy')}
</td>
{renderFields.map((f) => (
<td key={f.fieldId} className="px-3 py-1.5 text-gray-700 max-w-[220px]">
<InlineCell
statusId={s.id}
field={f}
value={getFieldValue(s, f)}
currentStatus={s}
onSaved={(updated) => setStatuses((prev) => prev.map((x) => x.id === s.id ? { ...x, ...updated } : x))}
/>
</td>
))}
<td className={`px-2 py-1.5 sticky right-0 z-10 border-l border-gray-100 ${rowBg}`}>
<div className="flex gap-1 justify-end">
<Button size="sm" variant="secondary" onClick={() => setEditStatus(s)}>Edit</Button>
<Button size="sm" variant="danger" onClick={() => setDeleteStatus(s)}>Delete</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
{[
['Experiment Description', s.experiment_description],
['Vitals', s.vitals],
['Treatment', s.treatment],
['Notes', s.notes],
]
.filter(([, v]) => v)
.map(([label, value]) => (
<div key={label} className="flex flex-col">
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">{label}</dt>
<dd className="text-gray-800 mt-0.5 whitespace-pre-wrap">{value}</dd>
</div>
))}
</dl>
{!s.experiment_description && !s.vitals && !s.treatment && !s.notes && (
<p className="text-sm text-gray-400 italic">No details recorded for this date.</p>
)}
</div>
))}
</div>
)}
<AuditLogSection tableName="daily_statuses" recordId={undefined} />
{missingDates.length > 0 && (
<div className="mt-3 flex items-center justify-between px-4 py-3 bg-amber-50 border border-amber-200 rounded-xl">
<p className="text-sm text-amber-800">
<span className="font-semibold">{missingDates.length}</span> date{missingDates.length !== 1 ? 's' : ''} between the earliest and latest entries have no record.
</p>
<Button variant="secondary" size="sm" onClick={fillGaps} loading={fillingGaps}>
Create {missingDates.length} empty entr{missingDates.length === 1 ? 'y' : 'ies'}
</Button>
</div>
)}
<Modal isOpen={showAddStatus} onClose={() => setShowAddStatus(false)} title="Log Daily Status" size="lg">
<DailyStatusForm animalId={id} onSuccess={handleStatusSaved} onCancel={() => setShowAddStatus(false)} />
<SubjectAnalysisCharts statuses={statuses} dailyTemplate={template} />
<AuditLogSection tableName="daily_statuses" limit={5} />
{/* Danger zone */}
<div className="mt-10 border border-red-200 rounded-xl p-4 bg-red-50/40">
<h2 className="text-xs font-semibold text-red-600 uppercase tracking-wide mb-2">Danger Zone</h2>
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600">Permanently delete this subject and all its daily status records.</p>
<Button variant="danger" onClick={() => setShowDeleteSubject(true)}>Delete Subject</Button>
</div>
</div>
<Modal isOpen={showSubjectInfo} onClose={() => setShowSubjectInfo(false)} title="Subject Information" size="full">
<SubjectInfoForm
animal={animal}
subjectTemplate={subjectTemplate}
experimentId={animal?.experiment_id}
onTemplateUpdate={setSubjectTemplate}
onSaved={(updated) => { setAnimal(updated); setShowSubjectInfo(false); flash('Subject information saved.'); }}
onCancel={() => setShowSubjectInfo(false)}
/>
</Modal>
<Modal isOpen={!!editStatus} onClose={() => setEditStatus(null)} title="Edit Daily Status" size="lg">
<Modal isOpen={showAddStatus} onClose={() => setShowAddStatus(false)} title="Log Daily Status" size="full">
<DailyStatusForm
animalId={id}
experimentId={animal?.experiment_id}
template={template}
onTemplateUpdate={setTemplate}
onSuccess={handleStatusSaved}
onCancel={() => setShowAddStatus(false)}
/>
</Modal>
{(() => {
const editIdx = editStatus ? statuses.findIndex((s) => s.id === editStatus.id) : -1;
return (
<Modal isOpen={!!editStatus} onClose={() => setEditStatus(null)} title="Edit Daily Status" size="full">
<DailyStatusForm
existing={editStatus}
animalId={id}
experimentId={animal?.experiment_id}
template={template}
onTemplateUpdate={setTemplate}
onSuccess={handleStatusSaved}
onCancel={() => setEditStatus(null)}
onRequestDelete={() => setDeleteStatus(editStatus)}
hasPrev={editIdx > 0}
hasNext={editIdx < statuses.length - 1}
onNavigate={(direction, savedResult) => {
setStatuses((prev) => prev.map((s) => s.id === savedResult.id ? savedResult : s));
setEditStatus(statuses[direction === 'prev' ? editIdx - 1 : editIdx + 1]);
}}
/>
</Modal>
);
})()}
<ConfirmDialog
isOpen={!!deleteStatus}
@@ -168,7 +539,18 @@ export default function AnimalDetail() {
onConfirm={handleDeleteStatus}
loading={deleteLoading}
title="Delete Daily Status"
message={`Delete the status entry for ${deleteStatus ? format(new Date(deleteStatus.date), 'MMMM d, yyyy') : ''}?`}
message={`Delete the status entry for ${deleteStatus ? format(new Date(String(deleteStatus.date).slice(0, 10) + 'T12:00:00'), 'MMMM d, yyyy') : ''}? This cannot be undone.`}
confirmWord="delete"
/>
<ConfirmDialog
isOpen={showDeleteSubject}
onClose={() => setShowDeleteSubject(false)}
onConfirm={handleDeleteSubject}
loading={deleteSubjectLoading}
title="Delete Subject"
message={`This will permanently delete "${animal.animal_name}" and all ${statuses.length} daily status record${statuses.length !== 1 ? 's' : ''}. This cannot be undone.`}
confirmWord="delete"
/>
</div>
);
+764
View File
@@ -0,0 +1,764 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { format } from 'date-fns';
import { dailyStatusesApi, experimentsApi, analysesApi } from '../api/client';
import Button from '../components/ui/Button';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import DailyStatusForm from '../components/DailyStatusForm';
import Alert from '../components/ui/Alert';
import CsvAnalysis from '../components/CsvAnalysis';
import AuditLogSection from '../components/AuditLogSection';
import RunSequenceView from '../components/RunSequenceView';
import FileDropZone from '../components/FileDropZone';
function fmtDate(raw) {
return format(new Date(String(raw).slice(0, 10) + 'T12:00:00'), 'MMMM d, yyyy');
}
function fmtDateTime(raw) {
return format(new Date(raw), 'MMM d, yyyy · h:mm a');
}
function getFieldValue(status, field) {
if (field.builtin) return status[field.key];
return status.custom_fields?.[field.fieldId];
}
// ── Analysis summary card ─────────────────────────────────────────────────────
function AnalysisSummaryCard({ summary, statusId, onSummaryUpdated }) {
const { counts = {}, total, success_rate, computed_at } = summary;
const COLORS = {
Success: 'bg-green-100 text-green-800',
Failure: 'bg-red-100 text-red-800',
Other: 'bg-gray-100 text-gray-700',
};
const EXTRA = ['bg-indigo-100 text-indigo-800', 'bg-yellow-100 text-yellow-800', 'bg-purple-100 text-purple-800', 'bg-pink-100 text-pink-800'];
const catList = Object.entries(counts).sort(([, a], [, b]) => b - a);
let extraIdx = 0;
const [showForm, setShowForm] = useState(false);
return (
<div className="bg-white border border-gray-200 rounded-xl px-5 py-4 mb-6">
<div className="flex items-center justify-between mb-3">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Session metrics</p>
<div className="flex items-center gap-3">
{computed_at && <p className="text-[10px] text-gray-400">Updated {fmtDateTime(computed_at)}</p>}
{!showForm && (
<button
type="button"
onClick={() => setShowForm(true)}
className="text-[10px] text-gray-400 hover:text-indigo-600 border border-dashed border-gray-200 hover:border-indigo-300 rounded px-2 py-0.5 transition-colors"
>
Edit manually
</button>
)}
</div>
</div>
{showForm ? (
<ManualMetricsForm
statusId={statusId}
existing={summary}
onSaved={(updated) => {
onSummaryUpdated({ ...updated, computed_at: new Date().toISOString() });
setShowForm(false);
}}
onClose={() => setShowForm(false)}
/>
) : (
<div className="flex flex-wrap gap-3 items-center">
{catList.map(([cat, n]) => {
const cls = COLORS[cat] ?? EXTRA[extraIdx++ % EXTRA.length];
return (
<div key={cat} className={`flex flex-col items-center px-3 py-2 rounded-lg ${cls}`}>
<span className="text-xs font-medium">{cat}</span>
<span className="text-xl font-bold leading-tight">{n}</span>
</div>
);
})}
<div className="flex flex-col items-center px-3 py-2 rounded-lg bg-blue-50 text-blue-800">
<span className="text-xs font-medium">Total</span>
<span className="text-xl font-bold leading-tight">{total}</span>
</div>
{success_rate != null && (
<div className="flex flex-col items-center px-3 py-2 rounded-lg bg-indigo-50 text-indigo-800">
<span className="text-xs font-medium">Success rate</span>
<span className="text-xl font-bold leading-tight">{(success_rate * 100).toFixed(1)}%</span>
</div>
)}
</div>
)}
</div>
);
}
// ── Manual metrics form ───────────────────────────────────────────────────────
const DEFAULT_CATS = ['Success', 'Failure', 'Other'];
function ManualMetricsForm({ statusId, existing, onSaved, onClose }) {
const initCounts = () => {
const base = { ...existing?.counts };
for (const c of DEFAULT_CATS) if (!(c in base)) base[c] = 0;
return base;
};
const [counts, setCounts] = useState(initCounts);
const [newCat, setNewCat] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const total = Object.values(counts).reduce((a, b) => a + (Number(b) || 0), 0);
const successRate = total > 0 && counts['Success'] != null
? (Number(counts['Success']) || 0) / total
: null;
function setCount(cat, raw) {
const v = Math.max(0, parseInt(raw, 10) || 0);
setCounts((prev) => ({ ...prev, [cat]: v }));
}
function addCat() {
const name = newCat.trim();
if (!name || name in counts) return;
setCounts((prev) => ({ ...prev, [name]: 0 }));
setNewCat('');
}
function removeCat(cat) {
if (DEFAULT_CATS.includes(cat)) return;
setCounts((prev) => { const n = { ...prev }; delete n[cat]; return n; });
}
async function save() {
setSaving(true);
setError(null);
try {
const finalCounts = Object.fromEntries(
Object.entries(counts).map(([k, v]) => [k, Number(v) || 0])
);
await dailyStatusesApi.updateAnalysisSummary(statusId, {
counts: finalCounts,
total,
success_rate: successRate,
source_analysis_id: null,
});
onSaved({ counts: finalCounts, total, success_rate: successRate });
} catch (err) {
setError(err.message ?? 'Failed to save');
} finally {
setSaving(false);
}
}
return (
<div className="bg-white border border-gray-200 rounded-xl px-5 py-4 mb-6">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">Set metrics manually</p>
<div className="flex flex-wrap gap-3 items-end mb-3">
{Object.entries(counts).map(([cat, val]) => (
<div key={cat} className="flex flex-col items-center gap-1">
<span className="text-xs text-gray-500">{cat}</span>
<input
type="number" min={0} value={val}
onChange={(e) => setCount(cat, e.target.value)}
className="w-20 text-center border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
/>
{!DEFAULT_CATS.includes(cat) && (
<button type="button" onClick={() => removeCat(cat)}
className="text-[10px] text-red-400 hover:text-red-600">remove</button>
)}
</div>
))}
{/* Add custom category inline */}
<div className="flex flex-col items-center gap-1">
<span className="text-xs text-gray-400">+ category</span>
<div className="flex gap-1">
<input value={newCat} onChange={(e) => setNewCat(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') addCat(); }}
placeholder="Name…"
className="w-24 border border-dashed border-gray-300 rounded px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-indigo-400" />
<button type="button" onClick={addCat}
className="text-xs text-indigo-500 hover:text-indigo-700 px-1">Add</button>
</div>
</div>
</div>
<div className="flex items-center gap-4 text-xs text-gray-500 mb-3">
<span>Total: <strong className="text-gray-800">{total}</strong></span>
{successRate != null && (
<span>Success rate: <strong className="text-gray-800">{(successRate * 100).toFixed(1)}%</strong></span>
)}
</div>
{error && <p className="text-xs text-red-500 mb-2">{error}</p>}
<div className="flex gap-2">
<button type="button" onClick={save} disabled={saving}
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 transition-colors">
{saving ? 'Saving…' : 'Save'}
</button>
<button type="button" onClick={onClose}
className="px-3 py-1.5 rounded-lg text-sm text-gray-500 hover:text-gray-700 border border-gray-200 transition-colors">
Cancel
</button>
</div>
</div>
);
}
// ── Past analysis row (expandable) ────────────────────────────────────────────
function AnalysisRow({ analysis, onDelete }) {
const [expanded, setExpanded] = useState(false);
const [full, setFull] = useState(null);
const [loadingFull, setLoadingFull] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
async function expand() {
if (expanded) { setExpanded(false); return; }
setExpanded(true);
if (full) return;
setLoadingFull(true);
try {
const data = await analysesApi.get(analysis.id);
setFull(data);
} finally {
setLoadingFull(false);
}
}
async function handleDelete() {
setDeleting(true);
try {
await analysesApi.delete(analysis.id);
onDelete(analysis.id);
} finally {
setDeleting(false);
}
}
const counts = analysis.category_counts ?? {};
const summary = Object.entries(counts)
.sort(([, a], [, b]) => b.total - a.total)
.map(([cat, d]) => `${cat}: ${d.total}`)
.join(' · ');
return (
<div className="border border-gray-200 rounded-lg overflow-hidden">
<div
className="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-gray-50 transition-colors"
onClick={expand}
>
<div className="flex items-center gap-3 min-w-0">
<span className="text-gray-400 text-xs">{expanded ? '▾' : '▸'}</span>
<div className="min-w-0">
<p className="text-sm font-medium text-gray-800 truncate">
{analysis.file_name ?? 'Unnamed file'}
</p>
<p className="text-xs text-gray-400">
{fmtDateTime(analysis.created_at)}
{' · '}
<span className="font-mono">{analysis.note_col}</span>
{' · '}
{analysis.total_note_rows} note rows
</p>
</div>
</div>
<div className="flex items-center gap-3 ml-4 shrink-0">
<span className="text-xs text-gray-500 hidden sm:block">{summary}</span>
<button type="button" onClick={(e) => { e.stopPropagation(); setConfirmDelete(true); }}
className="text-xs text-red-400 hover:text-red-600 transition-colors">Delete</button>
</div>
</div>
{expanded && (
<div className="border-t border-gray-100 px-4 py-4 bg-gray-50/40">
{loadingFull && <p className="text-sm text-gray-400">Loading</p>}
{full && <AnalysisResultsView analysis={full} />}
</div>
)}
<ConfirmDialog
isOpen={confirmDelete}
onClose={() => setConfirmDelete(false)}
onConfirm={handleDelete}
loading={deleting}
title="Delete Analysis"
message="Remove this analysis record? The raw CSV is not affected."
/>
</div>
);
}
// ── Analysis results display (reused by both live and past) ───────────────────
export function AnalysisResultsView({ analysis }) {
const { timestamp_col, note_col, total_note_rows,
category_counts, consecutive_stats, runs } = analysis;
const customCategories = [];
function colorsFor(cat) {
const COLORS = {
Success: { bg: 'bg-green-100', text: 'text-green-800', dot: 'bg-green-500', border: 'border-green-300' },
Failure: { bg: 'bg-red-100', text: 'text-red-800', dot: 'bg-red-500', border: 'border-red-300' },
Other: { bg: 'bg-gray-100', text: 'text-gray-700', dot: 'bg-gray-400', border: 'border-gray-300' },
};
const EXTRA = [
{ bg: 'bg-indigo-100', text: 'text-indigo-800', dot: 'bg-indigo-500', border: 'border-indigo-300' },
{ bg: 'bg-yellow-100', text: 'text-yellow-800', dot: 'bg-yellow-500', border: 'border-yellow-300' },
{ bg: 'bg-purple-100', text: 'text-purple-800', dot: 'bg-purple-500', border: 'border-purple-300' },
];
if (COLORS[cat]) return COLORS[cat];
const idx = customCategories.indexOf(cat) % EXTRA.length;
return EXTRA[Math.max(0, idx)];
}
return (
<div className="space-y-5 text-sm">
<p className="text-xs text-gray-400">
{total_note_rows} note rows · timestamp: <span className="font-mono">{timestamp_col}</span>
{' '}/ note: <span className="font-mono">{note_col}</span>
</p>
{/* Category counts */}
<div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1.5">Category Counts</p>
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-white border-b border-gray-200">
<th className="text-left px-3 py-1.5 text-xs font-semibold text-gray-500">Category</th>
<th className="text-left px-3 py-1.5 text-xs font-semibold text-gray-500">Breakdown</th>
<th className="text-right px-3 py-1.5 text-xs font-semibold text-gray-500">Total</th>
</tr>
</thead>
<tbody>
{Object.entries(category_counts ?? {})
.sort(([, a], [, b]) => b.total - a.total)
.map(([cat, data]) => {
const c = colorsFor(cat);
const bd = Object.entries(data.byNote ?? {})
.sort(([, a], [, b]) => b - a)
.map(([n, cnt]) => `${n}: ${cnt}`).join(' · ');
return (
<tr key={cat} className="border-b border-gray-100">
<td className="px-3 py-1.5">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ${c.bg} ${c.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
{cat}
</span>
</td>
<td className="px-3 py-1.5 text-gray-500 font-mono text-xs">{bd}</td>
<td className="px-3 py-1.5 text-right font-semibold text-gray-800">{data.total}</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Consecutive stats */}
{Object.keys(consecutive_stats ?? {}).length > 0 && (
<div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1.5">Consecutive Runs</p>
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-white border-b border-gray-200">
<th className="text-left px-3 py-1.5 text-xs font-semibold text-gray-500">Category</th>
<th className="text-right px-3 py-1.5 text-xs font-semibold text-gray-500">Runs</th>
<th className="text-right px-3 py-1.5 text-xs font-semibold text-gray-500">Max</th>
<th className="text-right px-3 py-1.5 text-xs font-semibold text-gray-500">Avg</th>
<th className="text-left px-3 py-1.5 text-xs font-semibold text-gray-500">Lengths</th>
</tr>
</thead>
<tbody>
{Object.entries(consecutive_stats).map(([cat, s]) => {
const c = colorsFor(cat);
const summary = Object.entries(
s.runLengths.reduce((acc, l) => { acc[l] = (acc[l] || 0) + 1; return acc; }, {})
).sort(([a], [b]) => Number(a) - Number(b))
.map(([l, cnt]) => `${l}×${cnt}`).join(', ');
return (
<tr key={cat} className="border-b border-gray-100">
<td className="px-3 py-1.5">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ${c.bg} ${c.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
{cat}
</span>
</td>
<td className="px-3 py-1.5 text-right text-gray-700">{s.totalRuns}</td>
<td className="px-3 py-1.5 text-right text-gray-700">{s.maxRun}</td>
<td className="px-3 py-1.5 text-right text-gray-700">{s.avgRun.toFixed(2)}</td>
<td className="px-3 py-1.5 text-gray-500 font-mono text-xs">{summary}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<RunSequenceView runs={runs ?? []} sequence={analysis.sequence ?? []} sessionEndTs={analysis.session_end_ts ?? null} />
</div>
);
}
// ── Past analyses inline viewer ───────────────────────────────────────────────
function PastAnalysesViewer({ analyses, onDelete, onNewAnalysis }) {
const [selectedId, setSelectedId] = useState(() => analyses[0]?.id ?? null);
const [cache, setCache] = useState({});
const [loadingId, setLoadingId] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
const [deleting, setDeleting] = useState(false);
// Load full data whenever the selected id changes
useEffect(() => {
if (!selectedId || cache[selectedId]) return;
setLoadingId(selectedId);
analysesApi.get(selectedId)
.then((data) => setCache((prev) => ({ ...prev, [selectedId]: data })))
.catch(() => {})
.finally(() => setLoadingId(null));
}, [selectedId]);
// If the list changes (new analysis added / deleted), keep selection valid
useEffect(() => {
if (!analyses.length) { setSelectedId(null); return; }
if (!analyses.find((a) => a.id === selectedId)) setSelectedId(analyses[0].id);
}, [analyses]);
async function handleDelete(id) {
setDeleting(true);
try {
await analysesApi.delete(id);
setCache((prev) => { const n = { ...prev }; delete n[id]; return n; });
onDelete(id);
} finally {
setDeleting(false);
setConfirmDelete(null);
}
}
if (!analyses.length) return null;
const selected = analyses.find((a) => a.id === selectedId) ?? null;
const fullData = selectedId ? (cache[selectedId] ?? null) : null;
const isLoading = loadingId === selectedId;
const categorySummary = (a) =>
Object.entries(a.category_counts ?? {})
.sort(([, x], [, y]) => y.total - x.total)
.map(([cat, d]) => `${cat}: ${d.total}`)
.join(' · ');
return (
<div className="mt-8 border-t border-gray-200 pt-6">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
Past Analyses ({analyses.length})
</h2>
</div>
{/* Tab selector — scrollable if many */}
{analyses.length > 1 && (
<div className="flex gap-1.5 overflow-x-auto pb-2 mb-4 scrollbar-thin">
{analyses.map((a) => {
const active = a.id === selectedId;
return (
<button
key={a.id}
type="button"
onClick={() => setSelectedId(a.id)}
className={`shrink-0 flex flex-col items-start px-3 py-2 rounded-lg border text-left transition-colors
${active
? 'bg-indigo-50 border-indigo-300 text-indigo-800'
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-300 hover:bg-gray-50'}`}
>
<span className="text-xs font-medium truncate max-w-[160px]">
{a.file_name ?? 'Unnamed file'}
</span>
<span className={`text-[10px] mt-0.5 ${active ? 'text-indigo-500' : 'text-gray-400'}`}>
{fmtDateTime(a.created_at)}
</span>
</button>
);
})}
</div>
)}
{/* Selected analysis panel */}
{selected && (
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{/* Panel header */}
<div className="flex items-start justify-between px-4 py-3 border-b border-gray-100 bg-gray-50/50">
<div>
<p className="text-sm font-medium text-gray-800">
{selected.file_name ?? 'Unnamed file'}
</p>
<p className="text-xs text-gray-400 mt-0.5">
{fmtDateTime(selected.created_at)}
{' · '}
<span className="font-mono">{selected.note_col}</span>
{' · '}
{selected.total_note_rows} note rows
{categorySummary(selected) && (
<span className="ml-2 text-gray-500">{categorySummary(selected)}</span>
)}
</p>
</div>
<button
type="button"
onClick={() => setConfirmDelete(selected.id)}
className="text-xs text-red-400 hover:text-red-600 transition-colors shrink-0 ml-4 mt-0.5"
>
Delete
</button>
</div>
{/* Panel body */}
<div className="px-4 py-4">
{isLoading && (
<p className="text-sm text-gray-400 py-4 text-center">Loading</p>
)}
{!isLoading && fullData && <AnalysisResultsView analysis={fullData} />}
</div>
</div>
)}
<ConfirmDialog
isOpen={!!confirmDelete}
onClose={() => setConfirmDelete(null)}
onConfirm={() => handleDelete(confirmDelete)}
loading={deleting}
title="Delete Analysis"
message="Remove this analysis record? The raw CSV is not affected."
/>
</div>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function DailyStatusDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [status, setStatus] = useState(null);
const [template, setTemplate] = useState([]);
const [allStatuses, setAllStatuses] = useState([]);
const [analyses, setAnalyses] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showEdit, setShowEdit] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [showManualForm, setShowManualForm] = useState(false);
async function load(statusId = id) {
setLoading(true);
setError(null);
try {
const s = await dailyStatusesApi.get(statusId);
const [tmpl, siblings, pastAnalyses] = await Promise.all([
experimentsApi.getTemplate(s.animal.experiment_id),
dailyStatusesApi.list(s.animal_id),
analysesApi.listForStatus(statusId),
]);
setStatus(s);
setTemplate(tmpl);
setAllStatuses(siblings);
setAnalyses(pastAnalyses);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, [id]);
async function handleDelete() {
setDeleteLoading(true);
try {
await dailyStatusesApi.delete(status.id);
navigate(`/animals/${status.animal_id}`, { replace: true });
} catch (err) {
setError(err.message);
setShowDelete(false);
} finally {
setDeleteLoading(false);
}
}
if (loading) return <div className="max-w-3xl mx-auto px-4 py-8 text-gray-400">Loading</div>;
if (error) return (
<div className="max-w-3xl mx-auto px-4 py-8">
<Alert type="error" message={error} />
<Button variant="secondary" className="mt-4" onClick={() => navigate(-1)}> Back</Button>
</div>
);
const animal = status.animal;
const activeFields = template.filter((f) => f.active);
const currentIdx = allStatuses.findIndex((s) => s.id === status.id);
const prevStatus = currentIdx > 0 ? allStatuses[currentIdx - 1] : null;
const nextStatus = currentIdx < allStatuses.length - 1 ? allStatuses[currentIdx + 1] : null;
return (
<div className="max-w-3xl mx-auto px-4 py-8">
{/* Breadcrumb */}
<nav className="text-sm text-gray-500 mb-4">
<Link to="/" className="hover:text-blue-600">Experiments</Link>
<span className="mx-2">/</span>
<Link to={`/experiments/${animal.experiment_id}`} className="hover:text-blue-600">Experiment</Link>
<span className="mx-2">/</span>
<Link to={`/animals/${animal.id}`} className="hover:text-blue-600">{animal.animal_name}</Link>
<span className="mx-2">/</span>
<span className="text-gray-900 font-medium">{fmtDate(status.date)}</span>
</nav>
{/* Header */}
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">{fmtDate(status.date)}</h1>
<p className="text-sm text-gray-500 mt-0.5">
{animal.animal_name}
{animal.animal_id_string && <> · <span className="font-mono">{animal.animal_id_string}</span></>}
</p>
</div>
<Button variant="secondary" onClick={() => setShowEdit(true)}>Edit entry</Button>
</div>
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
{/* Field values */}
<div className="bg-white border border-gray-200 rounded-xl p-6 mb-6">
{activeFields.length === 0 ? (
<p className="text-sm text-gray-400 italic">No template fields configured.</p>
) : (
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-4">
{activeFields.map((f) => {
const value = getFieldValue(status, f);
const label = f.unit ? `${f.label} (${f.unit})` : f.label;
return (
<div key={f.fieldId} className={f.type === 'textarea' ? 'sm:col-span-2' : ''}>
<dt className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-0.5">{label}</dt>
<dd className="text-gray-800 whitespace-pre-wrap break-words">
{value ?? <span className="text-gray-300 italic"></span>}
</dd>
</div>
);
})}
</dl>
)}
</div>
{/* Analysis summary (pinned metrics) */}
{status.analysis_summary ? (
<AnalysisSummaryCard
summary={status.analysis_summary}
statusId={status.id}
onSummaryUpdated={(summary) => setStatus((prev) => ({ ...prev, analysis_summary: summary }))}
/>
) : (
<div className="mb-4">
<button
type="button"
onClick={() => setShowManualForm(true)}
className="text-xs text-gray-400 hover:text-indigo-600 border border-dashed border-gray-200 hover:border-indigo-300 rounded-lg px-3 py-1.5 transition-colors"
>
Set metrics manually
</button>
</div>
)}
{showManualForm && !status.analysis_summary && (
<ManualMetricsForm
statusId={status.id}
existing={null}
onSaved={(summary) => {
setStatus((prev) => ({ ...prev, analysis_summary: { ...summary, computed_at: new Date().toISOString() } }));
setShowManualForm(false);
}}
onClose={() => setShowManualForm(false)}
/>
)}
{/* Past analyses — inline viewer */}
<PastAnalysesViewer
analyses={analyses}
onDelete={(deletedId) => setAnalyses((prev) => prev.filter((x) => x.id !== deletedId))}
/>
{/* CSV Analysis — after past analyses */}
<CsvAnalysis
dailyStatusId={status.id}
animal={status.animal}
onSaved={(saved) => setAnalyses((prev) => [saved, ...prev])}
onSummaryPushed={(summary) => setStatus((prev) => ({ ...prev, analysis_summary: { ...summary, computed_at: new Date().toISOString() } }))}
/>
{/* Session file registry */}
<FileDropZone
rows={[{ animal, status }]}
date={String(status.date).slice(0, 10)}
/>
{/* Full modification history for this entry */}
<AuditLogSection tableName="daily_statuses" recordId={status.id} />
{/* Prev / Next navigation */}
<div className="flex justify-between items-center mt-8 mb-6">
<div>
{prevStatus && (
<button type="button" onClick={() => navigate(`/daily-statuses/${prevStatus.id}`)}
className="text-sm text-blue-600 hover:text-blue-800 hover:underline">
{fmtDate(prevStatus.date)}
</button>
)}
</div>
<Link to={`/animals/${animal.id}`} className="text-sm text-gray-500 hover:text-gray-800">
All entries
</Link>
<div>
{nextStatus && (
<button type="button" onClick={() => navigate(`/daily-statuses/${nextStatus.id}`)}
className="text-sm text-blue-600 hover:text-blue-800 hover:underline">
{fmtDate(nextStatus.date)}
</button>
)}
</div>
</div>
{/* Edit modal */}
<Modal isOpen={showEdit} onClose={() => setShowEdit(false)} title="Edit Daily Status" size="full">
<DailyStatusForm
existing={status}
animalId={animal.id}
experimentId={animal.experiment_id}
template={template}
onTemplateUpdate={setTemplate}
onSuccess={(updated) => { setStatus({ ...updated, animal }); setShowEdit(false); }}
onCancel={() => setShowEdit(false)}
onRequestDelete={() => { setShowEdit(false); setShowDelete(true); }}
/>
</Modal>
<ConfirmDialog
isOpen={showDelete}
onClose={() => setShowDelete(false)}
onConfirm={handleDelete}
loading={deleteLoading}
title="Delete Daily Status"
message={`Delete the entry for ${fmtDate(status.date)}? This cannot be undone.`}
confirmWord="delete"
/>
</div>
);
}
+1 -1
View File
@@ -83,7 +83,7 @@ export default function Dashboard() {
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
{loading && (
<div className="text-center py-16 text-gray-400">Loading experiments</div>
<div className="text-center py-16 text-gray-400" aria-live="polite" aria-busy="true">Loading experiments</div>
)}
{!loading && experiments.length === 0 && (
+397
View File
@@ -0,0 +1,397 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom';
import { format } from 'date-fns';
import {
ResponsiveContainer, BarChart, Bar, XAxis, YAxis,
CartesianGrid, Tooltip, Legend,
} from 'recharts';
import { experimentsApi, dailyStatusesApi } from '../api/client';
import Button from '../components/ui/Button';
import Modal from '../components/ui/Modal';
import DailyStatusForm from '../components/DailyStatusForm';
import Alert from '../components/ui/Alert';
import FileDropZone from '../components/FileDropZone';
const BUILTIN_KEYS_SET = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
function getFieldValue(status, field) {
if (field.builtin) return status[field.key];
return status.custom_fields?.[field.fieldId];
}
function cellText(value) {
if (value === null || value === undefined || String(value).trim() === '') return null;
return String(value);
}
// ── Inline cell editor ────────────────────────────────────────────────────────
function CellEdit({ value, field, saving, onSave, onCancel }) {
const [val, setVal] = useState(value ?? '');
const ref = useRef(null);
useEffect(() => {
ref.current?.focus();
ref.current?.select();
}, []);
function handleKey(e) {
if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
if (e.key === 'Enter' && field.type !== 'textarea') { e.preventDefault(); onSave(val); }
}
const cls = 'w-full text-sm border border-blue-400 rounded px-1.5 py-0.5 focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white disabled:opacity-60';
if (field.type === 'textarea') {
return (
<textarea
ref={ref} value={val} rows={3} disabled={saving}
onChange={(e) => setVal(e.target.value)}
onBlur={() => onSave(val)}
onKeyDown={handleKey}
className={cls}
/>
);
}
return (
<input
ref={ref} type="text" value={val} disabled={saving}
onChange={(e) => setVal(e.target.value)}
onBlur={() => onSave(val)}
onKeyDown={handleKey}
className={cls}
/>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function ExperimentDayView() {
const { id, date } = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [experiment, setExperiment] = useState(null);
const [statuses, setStatuses] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [editStatus, setEditStatus] = useState(null);
// Inline cell editing
const [editingCell, setEditingCell] = useState(null); // { statusId, fieldId }
const [cellSaving, setCellSaving] = useState(false);
const [cellError, setCellError] = useState(null);
// Day navigation
const [dayList, setDayList] = useState([]);
const selectedField = searchParams.get('field') ?? null;
useEffect(() => {
setLoading(true);
Promise.all([
experimentsApi.get(id),
experimentsApi.getDayStatuses(id, date),
])
.then(([exp, s]) => {
setExperiment(exp);
setStatuses(s);
})
.catch((err) => setError(err.message ?? 'Failed to load'))
.finally(() => setLoading(false));
}, [id, date]);
// Fetch ordered list of days with data for this field, for prev/next nav
useEffect(() => {
if (!id || !selectedField) return;
experimentsApi.getCalendar(id, selectedField)
.then(({ days }) => setDayList(Object.keys(days).sort()))
.catch(() => {});
}, [id, selectedField]);
const dailyTemplate = useMemo(() => {
if (!experiment) return [];
return (experiment.template ?? []).filter((f) => f.active);
}, [experiment]);
const animals = useMemo(() => experiment?.animals ?? [], [experiment]);
const statusByAnimal = useMemo(() => {
const map = {};
statuses.forEach((s) => { map[s.animal_id] = s; });
return map;
}, [statuses]);
const rows = useMemo(() => {
return animals
.map((animal) => ({ animal, status: statusByAnimal[animal.id] }))
.filter(({ status }) => {
if (!status) return false;
if (!selectedField) return true;
const isBuiltin = BUILTIN_KEYS_SET.has(selectedField);
const val = isBuiltin ? status[selectedField] : status.custom_fields?.[selectedField];
return val !== null && val !== undefined && String(val).trim() !== '';
});
}, [animals, statusByAnimal, selectedField]);
const barData = useMemo(() => {
return rows
.map(({ animal, status }) => {
const summary = status?.analysis_summary;
if (!summary || summary.total === 0) return null;
return {
name: animal.animal_name || animal.animal_id_string || animal.id.slice(0, 8),
total: summary.total,
success: summary.counts?.Success ?? 0,
failure: summary.counts?.Failure ?? 0,
};
})
.filter(Boolean);
}, [rows]);
// Prev / next days with data
const currentDayIdx = dayList.indexOf(date);
const prevDay = currentDayIdx > 0 ? dayList[currentDayIdx - 1] : null;
const nextDay = currentDayIdx < dayList.length - 1 ? dayList[currentDayIdx + 1] : null;
function dayNavUrl(d) {
return `/experiments/${id}/day/${d}${selectedField ? `?field=${selectedField}` : ''}`;
}
// Inline cell save
async function saveCell(status, field, rawValue) {
const value = rawValue?.trim() || null;
// Skip save if unchanged
const current = getFieldValue(status, field);
const currentNorm = current == null ? null : String(current).trim() || null;
if (value === currentNorm) { setEditingCell(null); return; }
setCellSaving(true);
setCellError(null);
try {
let payload;
if (field.builtin) {
payload = { [field.key]: value };
} else {
payload = { custom_fields: { ...status.custom_fields, [field.fieldId]: value } };
}
const updated = await dailyStatusesApi.update(status.id, payload);
setStatuses((prev) => prev.map((s) => s.id === updated.id ? updated : s));
setEditingCell(null);
} catch (err) {
setCellError(err.message ?? 'Save failed');
} finally {
setCellSaving(false);
}
}
const displayDate = date
? format(new Date(date + 'T12:00:00'), 'MMMM d, yyyy')
: date;
if (loading) {
return <div className="max-w-5xl mx-auto px-4 py-10 text-center text-gray-400">Loading</div>;
}
if (error) {
return (
<div className="max-w-5xl mx-auto px-4 py-10">
<Alert type="error" message={error} />
</div>
);
}
return (
<div className="max-w-6xl mx-auto px-4 py-6">
{/* Breadcrumb */}
<nav className="text-sm text-gray-400 mb-4 flex items-center gap-1.5">
<Link to="/" className="hover:text-blue-600 transition-colors">Dashboard</Link>
<span>/</span>
<Link to={`/experiments/${id}`} className="hover:text-blue-600 transition-colors">
{experiment?.title ?? id}
</Link>
<span>/</span>
<span className="text-gray-700 font-medium">{displayDate}</span>
</nav>
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-bold text-gray-900">{displayDate}</h1>
<Button variant="secondary" onClick={() => navigate(`/experiments/${id}`)}>
Back to experiment
</Button>
</div>
{cellError && (
<Alert type="error" message={cellError} onDismiss={() => setCellError(null)} className="mb-4" />
)}
{/* Status table */}
{rows.length === 0 ? (
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl text-gray-400">
No data recorded for this day.
</div>
) : (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden mb-8">
<div className="overflow-x-auto">
<table className="text-sm border-collapse min-w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap sticky left-0 bg-gray-50 z-10 border-r border-gray-200 min-w-[140px]">
Subject
</th>
{dailyTemplate.map((f) => {
const header = f.abbr
? (f.unit ? `${f.abbr} (${f.unit})` : f.abbr)
: (f.unit ? `${f.label} (${f.unit})` : f.label);
return (
<th
key={f.fieldId}
title={f.unit ? `${f.label} (${f.unit})` : f.label}
className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap max-w-[220px]"
style={{ minWidth: Math.max(80, header.length * 7.5 + 24) }}
>
{header}
</th>
);
})}
<th className="sticky right-0 bg-gray-50 z-10 border-l border-gray-200 px-3 py-2 min-w-[80px]" />
</tr>
</thead>
<tbody>
{rows.map(({ animal, status }, i) => {
const rowBg = i % 2 === 0 ? 'bg-white' : 'bg-gray-50/40';
return (
<tr key={animal.id} className={`border-b border-gray-100 last:border-0 ${rowBg}`}>
{/* Subject name → daily status detail */}
<td
className={`px-3 py-2 font-medium whitespace-nowrap sticky left-0 z-10 border-r border-gray-100 ${rowBg} ${
status
? 'text-blue-600 cursor-pointer hover:text-blue-800 hover:bg-blue-50/40 transition-colors'
: 'text-gray-500'
}`}
onClick={() => status && navigate(`/daily-statuses/${status.id}`)}
>
{animal.animal_name ?? animal.animal_id_string ?? animal.id.slice(0, 8)}
{animal.animal_id_string && animal.animal_name && (
<span className="ml-1.5 text-xs text-gray-400 font-normal">
{animal.animal_id_string}
</span>
)}
</td>
{/* Field cells — click to edit inline */}
{dailyTemplate.map((f) => {
const isEditing = editingCell?.statusId === status?.id && editingCell?.fieldId === f.fieldId;
const text = status ? cellText(getFieldValue(status, f)) : null;
return (
<td key={f.fieldId} className="px-3 py-1.5 text-gray-700 max-w-[220px] align-top">
{isEditing ? (
<CellEdit
value={text}
field={f}
saving={cellSaving}
onSave={(val) => saveCell(status, f, val)}
onCancel={() => setEditingCell(null)}
/>
) : (
<span
className="whitespace-pre-wrap break-words block rounded px-0.5 -mx-0.5 cursor-pointer hover:bg-blue-50 transition-colors"
title="Click to edit"
onClick={() => status && setEditingCell({ statusId: status.id, fieldId: f.fieldId })}
>
{text ?? <span className="text-gray-300"></span>}
</span>
)}
</td>
);
})}
<td className={`px-2 py-1.5 sticky right-0 z-10 border-l border-gray-100 ${rowBg}`}>
{status && (
<Button size="sm" variant="secondary" onClick={() => setEditStatus(status)}>
Edit
</Button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* Session metrics bar chart */}
{barData.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-5 mb-8">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Session metrics</h3>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={barData} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="total" name="Total" fill="#94a3b8" radius={[3, 3, 0, 0]} />
<Bar dataKey="success" name="Success" fill="#22c55e" radius={[3, 3, 0, 0]} />
<Bar dataKey="failure" name="Failure" fill="#f87171" radius={[3, 3, 0, 0]} minPointSize={2} />
</BarChart>
</ResponsiveContainer>
</div>
)}
{/* Prev / next day navigation */}
{(prevDay || nextDay) && (
<div className="flex justify-between items-center mt-2 mb-4">
<div>
{prevDay && (
<button
type="button"
onClick={() => navigate(dayNavUrl(prevDay))}
className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
>
{format(new Date(prevDay + 'T12:00:00'), 'MMMM d, yyyy')}
</button>
)}
</div>
<div>
{nextDay && (
<button
type="button"
onClick={() => navigate(dayNavUrl(nextDay))}
className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
>
{format(new Date(nextDay + 'T12:00:00'), 'MMMM d, yyyy')}
</button>
)}
</div>
</div>
)}
{/* Session file drop zone */}
<FileDropZone rows={rows} date={date} />
{/* Edit modal (full row) */}
{editStatus && (
<Modal
isOpen={!!editStatus}
onClose={() => setEditStatus(null)}
title="Edit daily status"
size="lg"
>
<DailyStatusForm
existing={editStatus}
animalId={editStatus.animal_id}
experimentId={id}
template={dailyTemplate}
onSuccess={(updated) => {
setStatuses((prev) => prev.map((s) => s.id === updated.id ? updated : s));
setEditStatus(null);
}}
onCancel={() => setEditStatus(null)}
/>
</Modal>
)}
</div>
);
}
+319 -51
View File
@@ -1,12 +1,53 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useMemo } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { experimentsApi, animalsApi } from '../api/client';
import { ExperimentAnalysisCharts } from '../components/AnalysisCharts';
import Button from '../components/ui/Button';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import AnimalForm from '../components/AnimalForm';
import TemplateEditor from '../components/TemplateEditor';
import Alert from '../components/ui/Alert';
import AuditLogSection from '../components/AuditLogSection';
import ExperimentCalendar from '../components/ExperimentCalendar';
import ExportDataModal from '../components/ExportDataModal';
function AnimalCardList({ animals, subjectTemplate, onNavigate, onEdit }) {
return (
<div className="grid gap-3">
{animals.map((animal) => {
const infoFields = subjectTemplate.filter((f) => f.active && animal.subject_info?.[f.fieldId] != null);
return (
<div
key={animal.id}
className="bg-white border border-gray-200 rounded-xl p-4 flex items-center justify-between hover:border-blue-300 hover:shadow-sm transition-all cursor-pointer group"
onClick={() => onNavigate(animal)}
>
<div>
<div className="font-semibold text-gray-900 group-hover:text-blue-700 transition-colors">
{animal.animal_name}
</div>
<div className="text-sm text-gray-500">
ID: {animal.animal_id_string} · {animal._count?.daily_statuses ?? 0} daily status entries
</div>
{infoFields.length > 0 && (
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-1">
{infoFields.map((f) => (
<span key={f.fieldId} className="text-xs text-gray-400">
<span className="font-medium text-gray-500">{f.label}:</span> {animal.subject_info[f.fieldId]}
</span>
))}
</div>
)}
</div>
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<Button size="sm" variant="secondary" onClick={() => onEdit(animal)}>Edit</Button>
</div>
</div>
);
})}
</div>
);
}
export default function ExperimentDetail() {
const { id } = useParams();
@@ -20,19 +61,51 @@ export default function ExperimentDetail() {
const [showAddAnimal, setShowAddAnimal] = useState(false);
const [editAnimal, setEditAnimal] = useState(null);
const [deleteAnimal, setDeleteAnimal] = useState(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [showTemplate, setShowTemplate] = useState(false);
const [showSubjectTemplate, setShowSubjectTemplate] = useState(false);
const [showExport, setShowExport] = useState(false);
const [subjectTemplate, setSubjectTemplate] = useState([]);
const [dailyTemplate, setDailyTemplate] = useState([]);
const [experimentStatuses, setExperimentStatuses] = useState([]);
const [displayMode, setDisplayMode] = useState(() => localStorage.getItem(`exp-display-mode-${id}`) ?? 'list');
const [sortField, setSortField] = useState(() => localStorage.getItem(`exp-subject-sort-${id}`) ?? '__name__');
const [sortDir, setSortDir] = useState(() => localStorage.getItem(`exp-subject-sort-dir-${id}`) ?? 'asc');
const [groupField, setGroupField] = useState(() => localStorage.getItem(`exp-subject-group-${id}`) ?? '__name__');
function updateDisplayMode(mode) {
setDisplayMode(mode);
localStorage.setItem(`exp-display-mode-${id}`, mode);
}
function updateSort(field, dir) {
setSortField(field);
setSortDir(dir);
localStorage.setItem(`exp-subject-sort-${id}`, field);
localStorage.setItem(`exp-subject-sort-dir-${id}`, dir);
}
function updateGroupField(field) {
setGroupField(field);
localStorage.setItem(`exp-subject-group-${id}`, field);
}
async function load() {
setLoading(true);
setError(null);
try {
const [exp, anims] = await Promise.all([
const [exp, anims, subjTmpl, dailyTmpl, expStatuses] = await Promise.all([
experimentsApi.get(id),
animalsApi.list(id),
experimentsApi.getSubjectTemplate(id),
experimentsApi.getTemplate(id),
// Only fetch statuses that have saved analysis_summary — charts are the only consumer,
// and they filter for analysis_summary.total != null anyway.
experimentsApi.getDailyStatuses(id, { analysisOnly: true }),
]);
setExperiment(exp);
setAnimals(anims);
setSubjectTemplate(subjTmpl);
setDailyTemplate(dailyTmpl);
setExperimentStatuses(expStatuses);
} catch (err) {
setError(err.message);
} finally {
@@ -54,22 +127,59 @@ export default function ExperimentDetail() {
flash(editAnimal ? `Animal "${animal.animal_name}" updated.` : `Animal "${animal.animal_name}" added.`);
}
async function handleDeleteAnimal() {
setDeleteLoading(true);
try {
await animalsApi.delete(deleteAnimal.id);
setDeleteAnimal(null);
load();
flash('Animal removed.');
} catch (err) {
setError(err.message);
setDeleteAnimal(null);
} finally {
setDeleteLoading(false);
function handleTemplateSaved(updatedTemplate) {
setShowTemplate(false);
if (updatedTemplate) {
// Update local experiment state so everything downstream re-renders
setExperiment((prev) => ({ ...prev, template: updatedTemplate }));
flash('Record template updated.');
}
}
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400">Loading</div>;
const sortedAnimals = useMemo(() => {
const sorted = [...animals].sort((a, b) => {
let av, bv;
if (sortField === '__name__') {
av = a.animal_name ?? '';
bv = b.animal_name ?? '';
} else if (sortField === '__id__') {
av = a.animal_id_string ?? '';
bv = b.animal_id_string ?? '';
} else {
av = a.subject_info?.[sortField] ?? '';
bv = b.subject_info?.[sortField] ?? '';
}
// numeric-aware comparison
const an = parseFloat(av), bn = parseFloat(bv);
const cmp = (!isNaN(an) && !isNaN(bn)) ? an - bn : String(av).localeCompare(String(bv));
return sortDir === 'asc' ? cmp : -cmp;
});
return sorted;
}, [animals, sortField, sortDir, subjectTemplate]);
// Group mode: bucket sortedAnimals by the groupField value
const groupedAnimals = useMemo(() => {
if (displayMode !== 'group') return null;
const getVal = (animal) => {
if (groupField === '__name__') return animal.animal_name ?? '—';
if (groupField === '__id__') return animal.animal_id_string ?? '—';
const v = animal.subject_info?.[groupField];
return v != null && v !== '' ? String(v) : '—';
};
const buckets = new Map();
for (const animal of sortedAnimals) {
const key = getVal(animal);
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key).push(animal);
}
// Sort bucket keys numerically-aware
return [...buckets.entries()].sort(([a], [b]) => {
const an = parseFloat(a), bn = parseFloat(b);
return (!isNaN(an) && !isNaN(bn)) ? an - bn : a.localeCompare(b);
});
}, [sortedAnimals, displayMode, groupField]);
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400" aria-live="polite" aria-busy="true">Loading</div>;
if (error) return (
<div className="max-w-5xl mx-auto px-4 py-8">
<Alert type="error" message={error} />
@@ -93,65 +203,223 @@ export default function ExperimentDetail() {
{animals.length} animal{animals.length !== 1 ? 's' : ''} enrolled
</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setShowExport(true)}>Export data</Button>
<Button onClick={() => setShowAddAnimal(true)}>+ Add Animal</Button>
</div>
</div>
{successMsg && <Alert type="success" message={successMsg} />}
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
{/* Template strips */}
<div className="mb-5 space-y-2">
{/* Daily record template */}
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide w-24 shrink-0">Daily record</span>
{(experiment.template ?? []).map((f) => (
<span
key={f.key}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${
f.active ? 'bg-blue-50 border-blue-200 text-blue-700' : 'bg-gray-100 border-gray-200 text-gray-400'
}`}
>
{f.active ? null : <span title="hidden"></span>}
{f.label}
</span>
))}
<button
onClick={() => setShowTemplate(true)}
className="inline-flex items-center gap-1 text-xs text-gray-400 hover:text-blue-600 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536M9 13l6.586-6.586a2 2 0 112.828 2.828L11.828 15.828A2 2 0 0110 16.414V19h2.586a2 2 0 001.414-.586l6.5-6.5" />
</svg>
{(experiment.template ?? []).length === 0 ? 'Set up' : 'Edit'}
</button>
</div>
{/* Subject info template */}
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide w-24 shrink-0">Subject info</span>
{subjectTemplate.map((f) => (
<span
key={f.key}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${
f.active ? 'bg-emerald-50 border-emerald-200 text-emerald-700' : 'bg-gray-100 border-gray-200 text-gray-400'
}`}
>
{f.active ? null : <span title="hidden"></span>}
{f.label}
</span>
))}
<button
onClick={() => setShowSubjectTemplate(true)}
className="inline-flex items-center gap-1 text-xs text-gray-400 hover:text-emerald-600 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536M9 13l6.586-6.586a2 2 0 112.828 2.828L11.828 15.828A2 2 0 0110 16.414V19h2.586a2 2 0 001.414-.586l6.5-6.5" />
</svg>
{subjectTemplate.length === 0 ? 'Set up' : 'Edit'}
</button>
</div>
</div>
{animals.length === 0 ? (
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl">
<p className="text-gray-400 mb-3">No animals enrolled in this experiment yet.</p>
<Button onClick={() => setShowAddAnimal(true)}>+ Add First Animal</Button>
</div>
) : (
<div className="grid gap-3">
{animals.map((animal) => (
<div
key={animal.id}
className="bg-white border border-gray-200 rounded-xl p-4 flex items-center justify-between hover:border-blue-300 hover:shadow-sm transition-all cursor-pointer group"
onClick={() => navigate(`/animals/${animal.id}`)}
>
<div>
<div className="font-semibold text-gray-900 group-hover:text-blue-700 transition-colors">
{animal.animal_name}
</div>
<div className="text-sm text-gray-500">
ID: {animal.animal_id_string} · {animal._count?.daily_statuses ?? 0} daily status entries
</div>
</div>
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<Button size="sm" variant="secondary" onClick={() => setEditAnimal(animal)}>Edit</Button>
<Button size="sm" variant="danger" onClick={() => setDeleteAnimal(animal)}>Remove</Button>
</div>
</div>
<>
{/* Display controls */}
<div className="flex items-center gap-3 mb-3 flex-wrap">
{/* Mode toggle */}
<div className="flex rounded border border-gray-200 overflow-hidden text-xs">
{[['list', 'List'], ['group', 'Group']].map(([mode, label]) => (
<button key={mode} type="button" onClick={() => updateDisplayMode(mode)}
className={`px-3 py-1 border-l border-gray-200 first:border-l-0 transition-colors
${displayMode === mode ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
{label}
</button>
))}
</div>
{displayMode === 'list' && (
<>
<span className="text-xs text-gray-400">Sort by</span>
<select value={sortField} onChange={(e) => updateSort(e.target.value, sortDir)}
className="border border-gray-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400">
<option value="__name__">Name</option>
<option value="__id__">Subject ID</option>
{subjectTemplate.filter((f) => f.active).map((f) => (
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
))}
</select>
<button type="button" onClick={() => updateSort(sortField, sortDir === 'asc' ? 'desc' : 'asc')}
className="text-xs text-gray-500 hover:text-gray-800 border border-gray-200 rounded px-2 py-1 bg-white transition-colors">
{sortDir === 'asc' ? '↑ Asc' : '↓ Desc'}
</button>
</>
)}
<AuditLogSection tableName="animals" recordId={undefined} />
{displayMode === 'group' && (
<>
<span className="text-xs text-gray-400">Group by</span>
<select value={groupField} onChange={(e) => updateGroupField(e.target.value)}
className="border border-gray-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400">
<option value="__name__">Name</option>
<option value="__id__">Subject ID</option>
{subjectTemplate.filter((f) => f.active).map((f) => (
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
))}
</select>
<span className="text-xs text-gray-400 ml-1">· sorted within by</span>
<select value={sortField} onChange={(e) => updateSort(e.target.value, sortDir)}
className="border border-gray-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400">
<option value="__name__">Name</option>
<option value="__id__">Subject ID</option>
{subjectTemplate.filter((f) => f.active).map((f) => (
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
))}
</select>
<button type="button" onClick={() => updateSort(sortField, sortDir === 'asc' ? 'desc' : 'asc')}
className="text-xs text-gray-500 hover:text-gray-800 border border-gray-200 rounded px-2 py-1 bg-white transition-colors">
{sortDir === 'asc' ? '↑ Asc' : '↓ Desc'}
</button>
</>
)}
</div>
{displayMode === 'list' && (
<AnimalCardList
animals={sortedAnimals}
subjectTemplate={subjectTemplate}
onNavigate={(animal) => navigate(`/animals/${animal.id}`, { state: { template: experiment.template } })}
onEdit={setEditAnimal}
/>
)}
{displayMode === 'group' && groupedAnimals && groupedAnimals.map(([groupValue, groupAnimals]) => {
const groupLabel = (() => {
if (groupField === '__name__' || groupField === '__id__') return groupValue;
return subjectTemplate.find((f) => f.fieldId === groupField)?.label
? `${subjectTemplate.find((f) => f.fieldId === groupField).label}: ${groupValue}`
: groupValue;
})();
return (
<div key={groupValue} className="mb-5">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">{groupLabel}</span>
<span className="text-xs text-gray-400">({groupAnimals.length})</span>
<div className="flex-1 border-t border-gray-100" />
</div>
<AnimalCardList
animals={groupAnimals}
subjectTemplate={subjectTemplate}
onNavigate={(animal) => navigate(`/animals/${animal.id}`, { state: { template: experiment.template } })}
onEdit={setEditAnimal}
/>
</div>
);
})}
</>
)}
<ExperimentAnalysisCharts
animals={animals}
experimentStatuses={experimentStatuses}
subjectTemplate={subjectTemplate}
dailyTemplate={dailyTemplate}
plotConfig={experiment.plot_config}
onPersistConfig={(cfg) => experimentsApi.savePlotConfig(id, cfg)}
/>
{animals.length > 0 && (
<ExperimentCalendar
experimentId={id}
template={dailyTemplate}
/>
)}
<AuditLogSection tableName="animals" limit={5} />
{/* Template editor modal */}
<Modal isOpen={showTemplate} onClose={() => setShowTemplate(false)} title="Daily Record Template" size="full">
<TemplateEditor experimentId={id} onClose={handleTemplateSaved} />
</Modal>
<Modal isOpen={showSubjectTemplate} onClose={() => setShowSubjectTemplate(false)} title="Subject Info Template" size="full">
<TemplateEditor
experimentId={id}
loadFn={() => experimentsApi.getSubjectTemplate(id)}
saveFn={(fields) => experimentsApi.updateSubjectTemplate(id, fields)}
onClose={(updated) => {
setShowSubjectTemplate(false);
if (updated) { setSubjectTemplate(updated); flash('Subject info template updated.'); }
}}
/>
</Modal>
<Modal isOpen={showAddAnimal} onClose={() => setShowAddAnimal(false)} title="Add Animal">
<AnimalForm experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setShowAddAnimal(false)} />
</Modal>
<Modal isOpen={!!editAnimal} onClose={() => setEditAnimal(null)} title="Edit Animal">
<AnimalForm
existing={editAnimal}
<AnimalForm existing={editAnimal} experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setEditAnimal(null)} />
</Modal>
<Modal isOpen={showExport} onClose={() => setShowExport(false)} title="Export data" size="md">
<ExportDataModal
experimentId={id}
onSuccess={handleAnimalSaved}
onCancel={() => setEditAnimal(null)}
experimentTitle={experiment.title}
dailyTemplate={dailyTemplate}
animals={animals}
onClose={() => setShowExport(false)}
/>
</Modal>
<ConfirmDialog
isOpen={!!deleteAnimal}
onClose={() => setDeleteAnimal(null)}
onConfirm={handleDeleteAnimal}
loading={deleteLoading}
title="Remove Animal"
message={`Remove "${deleteAnimal?.animal_name}" and all its daily statuses from this experiment?`}
/>
</div>
);
}
+1
View File
@@ -15,5 +15,6 @@ module.exports = {
},
},
},
safelist: ['col-span-1', 'col-span-2', 'col-span-3', 'col-span-4', 'col-span-5', 'col-span-6', 'col-span-8', 'col-span-9', 'col-span-12'],
plugins: [],
};
+73
View File
@@ -0,0 +1,73 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AnimalForm from '../src/components/AnimalForm';
import * as client from '../src/api/client';
jest.mock('../src/api/client', () => ({
animalsApi: {
create: jest.fn(),
update: jest.fn(),
},
}));
const EXP_ID = 'exp-001';
const onSuccess = jest.fn();
const onCancel = jest.fn();
beforeEach(() => jest.clearAllMocks());
describe('AnimalForm', () => {
it('renders Animal ID and Animal Name fields', () => {
render(<AnimalForm experimentId={EXP_ID} onSuccess={onSuccess} onCancel={onCancel} />);
expect(screen.getByLabelText(/animal id/i)).toBeInTheDocument();
expect(screen.getByLabelText(/animal name/i)).toBeInTheDocument();
});
it('shows required field errors on empty submit', async () => {
render(<AnimalForm experimentId={EXP_ID} onSuccess={onSuccess} onCancel={onCancel} />);
fireEvent.click(screen.getByRole('button', { name: /add animal/i }));
expect(await screen.findAllByRole('alert')).toHaveLength(2);
expect(client.animalsApi.create).not.toHaveBeenCalled();
});
it('calls create with correct payload', async () => {
const animal = { id: 'a-1', animal_id_string: 'M-001', animal_name: 'Whiskers', experiment_id: EXP_ID };
client.animalsApi.create.mockResolvedValue(animal);
render(<AnimalForm experimentId={EXP_ID} onSuccess={onSuccess} onCancel={onCancel} />);
await userEvent.type(screen.getByLabelText(/animal id/i), 'M-001');
await userEvent.type(screen.getByLabelText(/animal name/i), 'Whiskers');
fireEvent.click(screen.getByRole('button', { name: /add animal/i }));
await waitFor(() =>
expect(client.animalsApi.create).toHaveBeenCalledWith({
animal_id_string: 'M-001',
animal_name: 'Whiskers',
experiment_id: EXP_ID,
})
);
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(animal));
});
it('pre-fills fields for existing animal and uses update API', async () => {
const existing = { id: 'a-1', animal_id_string: 'M-001', animal_name: 'Whiskers' };
const updated = { ...existing, animal_name: 'Mr Whiskers' };
client.animalsApi.update.mockResolvedValue(updated);
render(<AnimalForm existing={existing} experimentId={EXP_ID} onSuccess={onSuccess} onCancel={onCancel} />);
expect(screen.getByDisplayValue('Whiskers')).toBeInTheDocument();
const nameInput = screen.getByLabelText(/animal name/i);
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'Mr Whiskers');
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
await waitFor(() =>
expect(client.animalsApi.update).toHaveBeenCalledWith('a-1', {
animal_id_string: 'M-001',
animal_name: 'Mr Whiskers',
})
);
});
});
+42
View File
@@ -0,0 +1,42 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Button from '../src/components/ui/Button';
describe('Button component', () => {
it('renders with children text', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
it('calls onClick when clicked', () => {
const onClick = jest.fn();
render(<Button onClick={onClick}>Click</Button>);
fireEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Submit</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('is disabled and shows spinner when loading', () => {
render(<Button loading>Submit</Button>);
const btn = screen.getByRole('button');
expect(btn).toBeDisabled();
expect(btn.querySelector('svg')).toBeTruthy();
});
it('renders danger variant', () => {
render(<Button variant="danger">Delete</Button>);
const btn = screen.getByRole('button');
expect(btn.className).toContain('bg-red-600');
});
it('does not call onClick when disabled', () => {
const onClick = jest.fn();
render(<Button disabled onClick={onClick}>Click</Button>);
fireEvent.click(screen.getByRole('button'));
expect(onClick).not.toHaveBeenCalled();
});
});
+103
View File
@@ -0,0 +1,103 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import DailyStatusForm from '../src/components/DailyStatusForm';
import * as client from '../src/api/client';
jest.mock('../src/api/client', () => ({
dailyStatusesApi: {
create: jest.fn(),
update: jest.fn(),
},
experimentsApi: {
updateTemplate: jest.fn(),
},
}));
const ANIMAL_ID = 'animal-001';
const onSuccess = jest.fn();
const onCancel = jest.fn();
const BUILTIN_TEMPLATE = [
{ fieldId: 'f1', key: 'vitals', label: 'Vitals', type: 'textarea', builtin: true, active: true, width: 100 },
{ fieldId: 'f2', key: 'treatment', label: 'Treatment', type: 'textarea', builtin: true, active: true, width: 100 },
{ fieldId: 'f3', key: 'notes', label: 'Notes', type: 'textarea', builtin: true, active: true, width: 100 },
{ fieldId: 'f4', key: 'experiment_description', label: 'Experiment Description', type: 'textarea', builtin: true, active: true, width: 100 },
];
beforeEach(() => jest.clearAllMocks());
describe('DailyStatusForm', () => {
it('renders date field (required) and optional fields', () => {
render(<DailyStatusForm animalId={ANIMAL_ID} template={BUILTIN_TEMPLATE} onSuccess={onSuccess} onCancel={onCancel} />);
expect(screen.getByLabelText(/date/i)).toBeInTheDocument();
expect(screen.getByLabelText(/vitals/i)).toBeInTheDocument();
expect(screen.getByLabelText(/treatment/i)).toBeInTheDocument();
expect(screen.getByLabelText(/notes/i)).toBeInTheDocument();
expect(screen.getByLabelText(/experiment description/i)).toBeInTheDocument();
});
it('pre-fills date with today by default', () => {
render(<DailyStatusForm animalId={ANIMAL_ID} template={BUILTIN_TEMPLATE} onSuccess={onSuccess} onCancel={onCancel} />);
const today = new Date().toISOString().slice(0, 10);
expect(screen.getByLabelText(/date/i)).toHaveValue(today);
});
it('shows error when date is cleared', async () => {
render(<DailyStatusForm animalId={ANIMAL_ID} template={BUILTIN_TEMPLATE} onSuccess={onSuccess} onCancel={onCancel} />);
const dateInput = screen.getByLabelText(/date/i);
await userEvent.clear(dateInput);
fireEvent.click(screen.getByRole('button', { name: /log status/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/date is required/i);
expect(client.dailyStatusesApi.create).not.toHaveBeenCalled();
});
it('calls create with correct payload including animal_id', async () => {
const status = { id: 's-1', animal_id: ANIMAL_ID, date: '2024-06-01', vitals: 'HR 72' };
client.dailyStatusesApi.create.mockResolvedValue(status);
render(<DailyStatusForm animalId={ANIMAL_ID} template={BUILTIN_TEMPLATE} onSuccess={onSuccess} onCancel={onCancel} />);
const dateInput = screen.getByLabelText(/date/i);
fireEvent.change(dateInput, { target: { value: '2024-06-01' } });
await userEvent.type(screen.getByLabelText(/vitals/i), 'HR 72');
fireEvent.click(screen.getByRole('button', { name: /log status/i }));
await waitFor(() =>
expect(client.dailyStatusesApi.create).toHaveBeenCalledWith(
expect.objectContaining({ animal_id: ANIMAL_ID, vitals: 'HR 72' })
)
);
await waitFor(() => expect(onSuccess).toHaveBeenCalled());
});
it('shows "Save Changes" and pre-fills fields for existing status', () => {
const existing = {
id: 's-1',
date: '2024-06-01T00:00:00.000Z',
vitals: 'HR 72',
treatment: 'Control',
notes: 'Normal',
experiment_description: 'Baseline',
};
render(
<DailyStatusForm
existing={existing}
template={BUILTIN_TEMPLATE}
animalId={ANIMAL_ID}
onSuccess={onSuccess}
onCancel={onCancel}
/>
);
expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument();
expect(screen.getByDisplayValue('HR 72')).toBeInTheDocument();
});
it('calls onCancel when Cancel is clicked', () => {
render(<DailyStatusForm animalId={ANIMAL_ID} template={BUILTIN_TEMPLATE} onSuccess={onSuccess} onCancel={onCancel} />);
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(onCancel).toHaveBeenCalled();
});
});
@@ -0,0 +1,126 @@
import React from '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
// integration path (toggle, checkbox state, persistence). The legend onClick/hover
// wiring, line hide/strokeWidth, and tooltip ordering are exercised by the pure-helper
// unit tests (crossSubjectChart.test.js) and SubjectSidebar.test.jsx.
jest.mock('recharts', () => {
const Pass = ({ children }) => <div>{children}</div>;
return {
ResponsiveContainer: Pass, LineChart: Pass, Line: () => null,
XAxis: () => null, YAxis: () => null, CartesianGrid: () => null,
Tooltip: () => null, Legend: () => null,
};
});
import { ExperimentAnalysisCharts } from '../src/components/AnalysisCharts';
const ANIMALS = [
{ id: 'a1', animal_name: 'Mouse-A1', subject_info: { grp: 'Group A' } },
{ id: 'b1', animal_name: 'Mouse-B1', subject_info: { grp: 'Group B' } },
];
const STATUSES = [
{ animal_id: 'a1', date: '2026-01-01', analysis_summary: { total: 10, success_rate: 0.5, counts: { Success: 5 } } },
{ animal_id: 'b1', date: '2026-01-01', analysis_summary: { total: 8, success_rate: 0.25, counts: { Success: 2 } } },
];
const SUBJECT_TEMPLATE = [{ fieldId: 'grp', label: 'Group', active: true }];
const DAILY_TEMPLATE = [];
function renderChart() {
render(
<ExperimentAnalysisCharts
animals={ANIMALS}
experimentStatuses={STATUSES}
subjectTemplate={SUBJECT_TEMPLATE}
dailyTemplate={DAILY_TEMPLATE}
/>,
);
}
it('toggles the subject sidebar open and closed', () => {
renderChart();
expect(screen.queryByText('Subjects')).toBeNull();
fireEvent.click(screen.getByText('▸ Subjects'));
expect(screen.getByText('Subjects')).toBeInTheDocument();
expect(screen.getByLabelText('Mouse-A1')).toBeInTheDocument();
expect(screen.getByLabelText('Mouse-B1')).toBeInTheDocument();
fireEvent.click(screen.getByText('◂ Subjects'));
expect(screen.queryByText('Subjects')).toBeNull();
});
it('unchecking a subject in the sidebar persists across re-render', () => {
renderChart();
fireEvent.click(screen.getByText('▸ Subjects'));
const cb = screen.getByLabelText('Mouse-A1');
expect(cb.checked).toBe(true);
fireEvent.click(cb);
expect(screen.getByLabelText('Mouse-A1').checked).toBe(false);
expect(screen.getByText('Show all')).toBeInTheDocument();
});
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 }}
/>,
);
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'));
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(
<ExperimentAnalysisCharts
animals={ANIMALS} experimentStatuses={STATUSES}
subjectTemplate={SUBJECT_TEMPLATE} dailyTemplate={DAILY_TEMPLATE}
plotConfig={{ sidebarOpen: true }} onPersistConfig={onPersist}
/>,
);
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();
});
});
+186
View File
@@ -0,0 +1,186 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import ExperimentCalendar from '../src/components/ExperimentCalendar';
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
useNavigate: () => mockNavigate,
}));
const EXP_ID = 'aaaaaaaa-0000-0000-0000-000000000001';
const TEMPLATE = [
{ fieldId: '00000000-0000-0000-0000-000000000002', key: 'vitals', label: 'Vitals', active: true, builtin: true },
{ fieldId: 'ww000000-0000-0000-0000-000000000099', key: 'weights', label: 'Weights', active: true, builtin: false },
{ fieldId: 'zz000000-0000-0000-0000-000000000001', key: 'notes', label: 'Notes', active: false, builtin: true },
];
function todayStr() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function makeStatus(animalId, date, customFields = {}, vitals = null) {
return {
id: `s-${animalId}-${date}`,
animal_id: animalId,
date: `${date}T00:00:00.000Z`,
experiment_description: null,
vitals,
treatment: null,
notes: null,
custom_fields: customFields,
analysis_summary: null,
};
}
beforeEach(() => {
jest.clearAllMocks();
});
// ── Rendering ──────────────────────────────────────────────────────────────────
describe('ExperimentCalendar rendering', () => {
it('renders the current month and year', () => {
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} />);
const now = new Date();
const monthLabel = now.toLocaleString('en-US', { month: 'long', year: 'numeric' });
expect(screen.getByText(monthLabel)).toBeInTheDocument();
});
it('renders day-of-week headers', () => {
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} />);
for (const d of ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']) {
expect(screen.getByText(d)).toBeInTheDocument();
}
});
it('renders the field selector with active template fields only', () => {
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} />);
const options = screen.getAllByRole('option').map((o) => o.textContent);
expect(options).toContain('Vitals');
expect(options).toContain('Weights');
expect(options).not.toContain('Notes'); // inactive
});
});
// ── Default field selection ────────────────────────────────────────────────────
describe('default field selection', () => {
it('defaults to the field whose label contains "weight"', () => {
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} />);
expect(screen.getByTestId('field-select')).toHaveValue('ww000000-0000-0000-0000-000000000099');
});
it('defaults to first field if none contains "weight"', () => {
const noWeightTemplate = [
{ fieldId: 'ff000000-0000-0000-0000-000000000001', key: 'treatment', label: 'Treatment', active: true, builtin: true },
{ fieldId: 'ff000000-0000-0000-0000-000000000002', key: 'vitals', label: 'Vitals', active: true, builtin: true },
];
render(<ExperimentCalendar experimentId={EXP_ID} template={noWeightTemplate} allStatuses={[]} />);
expect(screen.getByTestId('field-select')).toHaveValue('treatment');
});
});
// ── Count badges (computed from allStatuses) ───────────────────────────────────
describe('count badges', () => {
it('shows a count badge for a day where the selected field is non-empty', () => {
const dateStr = todayStr();
const statuses = [
makeStatus('a1', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }),
makeStatus('a2', dateStr, { 'ww000000-0000-0000-0000-000000000099': '310' }),
];
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} />);
expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('2');
});
it('does not show count badge on days with no data', () => {
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} />);
expect(screen.queryByTestId(`count-${todayStr()}`)).not.toBeInTheDocument();
});
it('shows a count badge for builtin field (vitals)', () => {
const dateStr = todayStr();
const statuses = [makeStatus('a1', dateStr, {}, 'HR 72')];
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} />);
fireEvent.change(screen.getByTestId('field-select'), { target: { value: 'vitals' } });
expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('1');
});
it('recomputes counts after field change', () => {
const dateStr = todayStr();
const statuses = [makeStatus('a1', dateStr, {}, 'HR 72')];
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} />);
// Default is weights — no badge
expect(screen.queryByTestId(`count-${dateStr}`)).not.toBeInTheDocument();
// Switch to vitals — badge appears
fireEvent.change(screen.getByTestId('field-select'), { target: { value: 'vitals' } });
expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('1');
});
});
// ── Month navigation ──────────────────────────────────────────────────────────
describe('month navigation', () => {
it('moves to the previous month on click', () => {
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} />);
const now = new Date();
const prev = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const prevLabel = prev.toLocaleString('en-US', { month: 'long', year: 'numeric' });
fireEvent.click(screen.getByLabelText('Previous month'));
expect(screen.getByText(prevLabel)).toBeInTheDocument();
});
it('moves to the next month on click', () => {
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} />);
const now = new Date();
const next = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const nextLabel = next.toLocaleString('en-US', { month: 'long', year: 'numeric' });
fireEvent.click(screen.getByLabelText('Next month'));
expect(screen.getByText(nextLabel)).toBeInTheDocument();
});
});
// ── Day click navigation ──────────────────────────────────────────────────────
describe('day click navigation', () => {
it('navigates to the day view with field param when a day with data is clicked', () => {
const dateStr = todayStr();
const statuses = [
makeStatus('a1', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }),
];
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} />);
fireEvent.click(screen.getByTestId(`day-${dateStr}`));
expect(mockNavigate).toHaveBeenCalledWith(
`/experiments/${EXP_ID}/day/${dateStr}?field=ww000000-0000-0000-0000-000000000099`,
);
});
it('does not navigate when clicking a day without data (button is disabled)', () => {
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} />);
const dayBtn = screen.getByTestId(`day-${todayStr()}`);
expect(dayBtn).toBeDisabled();
fireEvent.click(dayBtn);
expect(mockNavigate).not.toHaveBeenCalled();
});
it('includes the currently selected field in the navigation URL', () => {
const dateStr = todayStr();
const statuses = [makeStatus('a1', dateStr, {}, 'HR 72')];
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} />);
// Switch to vitals field first
fireEvent.change(screen.getByTestId('field-select'), { target: { value: 'vitals' } });
fireEvent.click(screen.getByTestId(`day-${dateStr}`));
expect(mockNavigate).toHaveBeenCalledWith(
`/experiments/${EXP_ID}/day/${dateStr}?field=vitals`,
);
});
it('navigates with no field param when selectedField is null (no active fields)', () => {
const emptyTemplate = [];
render(<ExperimentCalendar experimentId={EXP_ID} template={emptyTemplate} allStatuses={[]} />);
// No days have data with empty template, so nothing to click — just verify no crash
expect(screen.queryByTestId(`count-${todayStr()}`)).not.toBeInTheDocument();
});
});
+639
View File
@@ -0,0 +1,639 @@
import React from 'react';
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
import ExperimentDayView from '../src/pages/ExperimentDayView';
// ── Mocks ──────────────────────────────────────────────────────────────────────
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
useParams: jest.fn(),
useSearchParams: jest.fn(),
useNavigate: () => mockNavigate,
Link: ({ to, children, ...props }) => <a href={to} {...props}>{children}</a>,
}));
const { useParams, useSearchParams } = require('react-router-dom');
const { experimentsApi, dailyStatusesApi } = require('../src/api/client');
jest.mock('../src/api/client', () => ({
experimentsApi: {
get: jest.fn(),
getDayStatuses: jest.fn(),
getCalendar: jest.fn(),
},
dailyStatusesApi: {
update: jest.fn(),
},
}));
// DailyStatusForm is a complex form component; stub it to keep tests focused
jest.mock('../src/components/DailyStatusForm', () =>
function MockDailyStatusForm({ onSuccess, onCancel }) {
return (
<div data-testid="daily-status-form">
<button onClick={() => onSuccess({ id: 'updated-id' })}>Submit</button>
<button onClick={onCancel}>Cancel</button>
</div>
);
}
);
// recharts uses ResizeObserver which is not available in jsdom
jest.mock('recharts', () => ({
ResponsiveContainer: ({ children }) => <div>{children}</div>,
BarChart: ({ children }) => <div data-testid="bar-chart">{children}</div>,
Bar: () => null,
XAxis: () => null,
YAxis: () => null,
CartesianGrid: () => null,
Tooltip: () => null,
Legend: () => null,
}));
// ── Fixtures ───────────────────────────────────────────────────────────────────
const EXP_ID = 'exp-0001-0000-0000-0000-000000000001';
const DATE_STR = '2026-04-23';
const FIELD_ID = 'ww000000-0000-0000-0000-000000000099';
const TEMPLATE = [
{ fieldId: '00000000-0000-0000-0000-000000000001', key: 'vitals', label: 'Vitals', type: 'text', builtin: true, active: true },
{ fieldId: FIELD_ID, key: 'weight', label: 'Weight', type: 'text', builtin: false, active: true },
{ fieldId: 'zz000000-0000-0000-0000-000000000001', key: 'notes', label: 'Notes', type: 'textarea', builtin: true, active: false },
];
const ANIMALS = [
{ id: 'a1000000-0000-0000-0000-000000000001', animal_name: 'Rat A', animal_id_string: 'R001' },
{ id: 'a2000000-0000-0000-0000-000000000002', animal_name: 'Rat B', animal_id_string: 'R002' },
{ id: 'a3000000-0000-0000-0000-000000000003', animal_name: 'Rat C', animal_id_string: 'R003' },
];
const EXPERIMENT = {
id: EXP_ID,
title: 'Reach Task Study',
template: TEMPLATE,
animals: ANIMALS,
};
function makeStatus(animalId, overrides = {}) {
return {
id: `status-${animalId}`,
animal_id: animalId,
date: `${DATE_STR}T00:00:00.000Z`,
experiment_description: null,
vitals: null,
treatment: null,
notes: null,
custom_fields: {},
analysis_summary: null,
...overrides,
};
}
function makeSearchParams(field = FIELD_ID) {
const params = new URLSearchParams(field ? `field=${field}` : '');
return [params, jest.fn()];
}
// ── Setup ──────────────────────────────────────────────────────────────────────
beforeEach(() => {
jest.clearAllMocks();
useParams.mockReturnValue({ id: EXP_ID, date: DATE_STR });
useSearchParams.mockReturnValue(makeSearchParams(FIELD_ID));
experimentsApi.get.mockResolvedValue(EXPERIMENT);
experimentsApi.getDayStatuses.mockResolvedValue([]);
experimentsApi.getCalendar.mockResolvedValue({ field: FIELD_ID, days: {} });
dailyStatusesApi.update.mockResolvedValue({});
});
// ── Loading & error states ────────────────────────────────────────────────────
describe('loading and error states', () => {
it('shows a loading indicator while data is being fetched', () => {
experimentsApi.get.mockReturnValue(new Promise(() => {})); // never resolves
render(<ExperimentDayView />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('shows an error message when the API call fails', async () => {
experimentsApi.get.mockRejectedValue({ message: 'Network error' });
render(<ExperimentDayView />);
await waitFor(() => expect(screen.getByText(/network error/i)).toBeInTheDocument());
});
it('shows empty state when no data for the day', async () => {
render(<ExperimentDayView />);
await waitFor(() =>
expect(screen.getByText(/no data recorded for this day/i)).toBeInTheDocument()
);
});
});
// ── Breadcrumb ────────────────────────────────────────────────────────────────
describe('breadcrumb', () => {
it('renders dashboard, experiment title, and formatted date', async () => {
render(<ExperimentDayView />);
await waitFor(() => screen.getByText('Reach Task Study'));
expect(screen.getByRole('link', { name: 'Dashboard' })).toHaveAttribute('href', '/');
expect(screen.getByRole('link', { name: 'Reach Task Study' })).toHaveAttribute('href', `/experiments/${EXP_ID}`);
expect(screen.getAllByText('April 23, 2026').length).toBeGreaterThan(0);
});
});
// ── Row filtering by selected field ──────────────────────────────────────────
describe('row filtering', () => {
it('shows animals that have a non-empty value for the selected field', async () => {
experimentsApi.getDayStatuses.mockResolvedValue([
makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: '325' } }),
makeStatus('a2000000-0000-0000-0000-000000000002', { custom_fields: { [FIELD_ID]: '310' } }),
]);
render(<ExperimentDayView />);
await waitFor(() => screen.getByText('Rat A'));
expect(screen.getByText('Rat A')).toBeInTheDocument();
expect(screen.getByText('Rat B')).toBeInTheDocument();
});
it('excludes animals whose selected field value is empty', async () => {
experimentsApi.getDayStatuses.mockResolvedValue([
makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: '325' } }),
makeStatus('a2000000-0000-0000-0000-000000000002', { custom_fields: {} }), // no weight
]);
render(<ExperimentDayView />);
await waitFor(() => screen.getByText('Rat A'));
expect(screen.queryByText('Rat B')).not.toBeInTheDocument();
});
it('excludes animals whose selected field value is null', async () => {
experimentsApi.getDayStatuses.mockResolvedValue([
makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: null } }),
]);
render(<ExperimentDayView />);
await waitFor(() =>
expect(screen.getByText(/no data recorded/i)).toBeInTheDocument()
);
});
it('excludes animals with no status record for that day', async () => {
// Only Rat A has a status; Rat B and C have no record returned
experimentsApi.getDayStatuses.mockResolvedValue([
makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: '325' } }),
]);
render(<ExperimentDayView />);
await waitFor(() => screen.getByText('Rat A'));
expect(screen.queryByText('Rat B')).not.toBeInTheDocument();
expect(screen.queryByText('Rat C')).not.toBeInTheDocument();
});
it('shows all animals with a status record when no field filter is set', async () => {
useSearchParams.mockReturnValue(makeSearchParams(null));
experimentsApi.getDayStatuses.mockResolvedValue([
makeStatus('a1000000-0000-0000-0000-000000000001'),
makeStatus('a2000000-0000-0000-0000-000000000002'),
]);
render(<ExperimentDayView />);
await waitFor(() => screen.getByText('Rat A'));
expect(screen.getByText('Rat A')).toBeInTheDocument();
expect(screen.getByText('Rat B')).toBeInTheDocument();
});
it('filters by builtin field (vitals)', async () => {
useSearchParams.mockReturnValue(makeSearchParams('vitals'));
experimentsApi.getDayStatuses.mockResolvedValue([
makeStatus('a1000000-0000-0000-0000-000000000001', { vitals: 'HR 72' }),
makeStatus('a2000000-0000-0000-0000-000000000002', { vitals: null }),
]);
render(<ExperimentDayView />);
await waitFor(() => screen.getByText('Rat A'));
expect(screen.queryByText('Rat B')).not.toBeInTheDocument();
});
});
// ── Table structure ───────────────────────────────────────────────────────────
describe('table structure', () => {
beforeEach(() => {
experimentsApi.getDayStatuses.mockResolvedValue([
makeStatus('a1000000-0000-0000-0000-000000000001', {
vitals: 'HR 72',
custom_fields: { [FIELD_ID]: '325' },
}),
]);
});
it('renders a column header for each active template field', async () => {
render(<ExperimentDayView />);
await waitFor(() => screen.getByText('Rat A'));
// Active fields: Vitals, Weight. Notes is inactive.
expect(screen.getByText('Vitals')).toBeInTheDocument();
expect(screen.getByText('Weight')).toBeInTheDocument();
expect(screen.queryByText('Notes')).not.toBeInTheDocument();
});
it('renders field values in the correct cells', async () => {
render(<ExperimentDayView />);
await waitFor(() => screen.getByText('325'));
expect(screen.getByText('HR 72')).toBeInTheDocument();
expect(screen.getByText('325')).toBeInTheDocument();
});
it('renders dash for empty field values', async () => {
experimentsApi.getDayStatuses.mockResolvedValue([
makeStatus('a1000000-0000-0000-0000-000000000001', {
vitals: null,
custom_fields: { [FIELD_ID]: '325' },
}),
]);
render(<ExperimentDayView />);
await waitFor(() => screen.getByText('Rat A'));
expect(screen.getByText('—')).toBeInTheDocument();
});
it('renders the subject name with animal_id_string alongside', async () => {
render(<ExperimentDayView />);
await waitFor(() => screen.getByText('Rat A'));
expect(screen.getByText('R001')).toBeInTheDocument();
});
it('renders an Edit button for each row', async () => {
render(<ExperimentDayView />);
await waitFor(() => screen.getByText('Rat A'));
expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument();
});
});
// ── Subject name navigation ───────────────────────────────────────────────────
describe('subject name click', () => {
it('navigates to /daily-statuses/:id when subject name is clicked', async () => {
const statusId = 'status-a1000000-0000-0000-0000-000000000001';
experimentsApi.getDayStatuses.mockResolvedValue([
makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: '325' } }),
]);
render(<ExperimentDayView />);
await waitFor(() => screen.getByText('Rat A'));
fireEvent.click(screen.getByText('Rat A'));
expect(mockNavigate).toHaveBeenCalledWith(`/daily-statuses/${statusId}`);
});
});
// ── Edit modal ────────────────────────────────────────────────────────────────
describe('edit modal', () => {
beforeEach(() => {
experimentsApi.getDayStatuses.mockResolvedValue([
makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: '325' } }),
]);
});
it('opens the edit modal when Edit button is clicked', async () => {
render(<ExperimentDayView />);
await waitFor(() => screen.getByText('Rat A'));
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
await waitFor(() =>
expect(screen.getByTestId('daily-status-form')).toBeInTheDocument()
);
});
it('closes the edit modal when Cancel is clicked', async () => {
render(<ExperimentDayView />);
await waitFor(() => screen.getByText('Rat A'));
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
await waitFor(() => screen.getByTestId('daily-status-form'));
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
await waitFor(() =>
expect(screen.queryByTestId('daily-status-form')).not.toBeInTheDocument()
);
});
it('closes the edit modal and updates the row after successful submit', async () => {
const originalStatus = makeStatus('a1000000-0000-0000-0000-000000000001', {
custom_fields: { [FIELD_ID]: '325' },
});
experimentsApi.getDayStatuses.mockResolvedValue([originalStatus]);
render(<ExperimentDayView />);
await waitFor(() => screen.getByText('Rat A'));
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
await waitFor(() => screen.getByTestId('daily-status-form'));
// Submit with updated status
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() =>
expect(screen.queryByTestId('daily-status-form')).not.toBeInTheDocument()
);
});
});
// ── Session metrics bar chart ─────────────────────────────────────────────────
//
// Bar chart reads from status.analysis_summary — pre-computed trial-level
// session metrics: { total, counts: { Success, Failure }, success_rate }
// X-axis: animals visible in the table (have non-empty selected field value).
// Hidden when no visible animal has analysis_summary.total > 0.
describe('session metrics bar chart', () => {
it('renders bar chart when at least one visible animal has analysis_summary', async () => {
const summary = { total: 18, counts: { Success: 5, Failure: 13 }, success_rate: 0.28 };
experimentsApi.getDayStatuses.mockResolvedValue([
makeStatus('a1000000-0000-0000-0000-000000000001', {
custom_fields: { [FIELD_ID]: '325' },
analysis_summary: summary,
}),
]);
render(<ExperimentDayView />);
await waitFor(() => screen.getByText('Rat A'));
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
});
it('hides bar chart when no visible animal has analysis_summary', async () => {
experimentsApi.getDayStatuses.mockResolvedValue([
makeStatus('a1000000-0000-0000-0000-000000000001', {
custom_fields: { [FIELD_ID]: '325' },
analysis_summary: null,
}),
]);
render(<ExperimentDayView />);
await waitFor(() => screen.getByText('Rat A'));
expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument();
});
it('hides bar chart when analysis_summary.total is 0', async () => {
const emptySummary = { total: 0, counts: { Success: 0, Failure: 0 }, success_rate: null };
experimentsApi.getDayStatuses.mockResolvedValue([
makeStatus('a1000000-0000-0000-0000-000000000001', {
custom_fields: { [FIELD_ID]: '325' },
analysis_summary: emptySummary,
}),
]);
render(<ExperimentDayView />);
await waitFor(() => screen.getByText('Rat A'));
expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument();
});
it('excludes from chart animals that are filtered out by the selected field', async () => {
// Rat A: has field value + analysis_summary → in table and chart
// Rat B: NO field value, has analysis_summary → excluded from both
const summary = { total: 10, counts: { Success: 7, Failure: 3 }, success_rate: 0.7 };
experimentsApi.getDayStatuses.mockResolvedValue([
makeStatus('a1000000-0000-0000-0000-000000000001', {
custom_fields: { [FIELD_ID]: '325' },
analysis_summary: summary,
}),
makeStatus('a2000000-0000-0000-0000-000000000002', {
custom_fields: {},
analysis_summary: summary,
}),
]);
render(<ExperimentDayView />);
await waitFor(() => screen.getByText('Rat A'));
// Chart still renders because Rat A has data
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
// Rat B not in table
expect(screen.queryByText('Rat B')).not.toBeInTheDocument();
});
it('shows bar chart for multiple animals each with their own analysis_summary', async () => {
experimentsApi.getDayStatuses.mockResolvedValue([
makeStatus('a1000000-0000-0000-0000-000000000001', {
custom_fields: { [FIELD_ID]: '325' },
analysis_summary: { total: 18, counts: { Success: 5, Failure: 13 }, success_rate: 0.28 },
}),
makeStatus('a2000000-0000-0000-0000-000000000002', {
custom_fields: { [FIELD_ID]: '310' },
analysis_summary: { total: 10, counts: { Success: 7, Failure: 3 }, success_rate: 0.7 },
}),
]);
render(<ExperimentDayView />);
await waitFor(() => screen.getByText('Rat A'));
expect(screen.getByText('Rat B')).toBeInTheDocument();
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
});
});
// ── API call parameters ───────────────────────────────────────────────────────
describe('API calls', () => {
it('calls getDayStatuses with the experiment id and date from the URL', async () => {
render(<ExperimentDayView />);
await waitFor(() =>
expect(experimentsApi.getDayStatuses).toHaveBeenCalledWith(EXP_ID, DATE_STR)
);
});
it('calls experimentsApi.get with the experiment id', async () => {
render(<ExperimentDayView />);
await waitFor(() =>
expect(experimentsApi.get).toHaveBeenCalledWith(EXP_ID)
);
});
it('calls getCalendar with experiment id and selected field for day navigation', async () => {
render(<ExperimentDayView />);
await waitFor(() =>
expect(experimentsApi.getCalendar).toHaveBeenCalledWith(EXP_ID, FIELD_ID)
);
});
});
// ── Inline cell editing ───────────────────────────────────────────────────────
describe('inline cell editing', () => {
const STATUS_ID = 'status-a1000000-0000-0000-0000-000000000001';
beforeEach(() => {
experimentsApi.getDayStatuses.mockResolvedValue([
makeStatus('a1000000-0000-0000-0000-000000000001', {
custom_fields: { [FIELD_ID]: '325' },
vitals: 'HR 72',
}),
]);
dailyStatusesApi.update.mockResolvedValue(
makeStatus('a1000000-0000-0000-0000-000000000001', {
custom_fields: { [FIELD_ID]: '330' },
vitals: 'HR 72',
})
);
});
it('shows an input when a cell value is clicked', async () => {
render(<ExperimentDayView />);
await waitFor(() => screen.getByText('325'));
fireEvent.click(screen.getByText('325'));
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
it('shows an input when a dash (empty cell) is clicked', async () => {
render(<ExperimentDayView />);
await waitFor(() => screen.getByText('Rat A'));
// Vitals is shown as "HR 72" but weight is selected, click on a dash cell in vitals column
// After the status above vitals = 'HR 72' (not empty), let's use a status without vitals
experimentsApi.getDayStatuses.mockResolvedValue([
makeStatus('a1000000-0000-0000-0000-000000000001', {
custom_fields: { [FIELD_ID]: '325' },
vitals: null,
}),
]);
// Re-render
const { unmount } = render(<ExperimentDayView />);
await waitFor(() => screen.getAllByText('Rat A'));
const dashCell = screen.getAllByText('—')[0];
fireEvent.click(dashCell);
expect(screen.getByRole('textbox')).toBeInTheDocument();
unmount();
});
it('saves a custom field value on Enter key', async () => {
render(<ExperimentDayView />);
await waitFor(() => screen.getByText('325'));
fireEvent.click(screen.getByText('325'));
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: '330' } });
fireEvent.keyDown(input, { key: 'Enter' });
await waitFor(() =>
expect(dailyStatusesApi.update).toHaveBeenCalledWith(
STATUS_ID,
expect.objectContaining({ custom_fields: expect.objectContaining({ [FIELD_ID]: '330' }) })
)
);
});
it('saves on blur', async () => {
render(<ExperimentDayView />);
await waitFor(() => screen.getByText('325'));
fireEvent.click(screen.getByText('325'));
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: '328' } });
fireEvent.blur(input);
await waitFor(() =>
expect(dailyStatusesApi.update).toHaveBeenCalledWith(
STATUS_ID,
expect.objectContaining({ custom_fields: expect.objectContaining({ [FIELD_ID]: '328' }) })
)
);
});
it('cancels edit on Escape without saving', async () => {
render(<ExperimentDayView />);
await waitFor(() => screen.getByText('325'));
fireEvent.click(screen.getByText('325'));
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: '999' } });
fireEvent.keyDown(input, { key: 'Escape' });
expect(dailyStatusesApi.update).not.toHaveBeenCalled();
await waitFor(() => expect(screen.queryByRole('textbox')).not.toBeInTheDocument());
});
it('skips the API call when the value is unchanged', async () => {
render(<ExperimentDayView />);
await waitFor(() => screen.getByText('325'));
fireEvent.click(screen.getByText('325'));
const input = screen.getByRole('textbox');
// value unchanged — blur without editing
fireEvent.blur(input);
expect(dailyStatusesApi.update).not.toHaveBeenCalled();
});
it('saves a builtin field (vitals) with the field key as payload key', async () => {
render(<ExperimentDayView />);
await waitFor(() => screen.getByText('HR 72'));
fireEvent.click(screen.getByText('HR 72'));
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: 'HR 80' } });
fireEvent.keyDown(input, { key: 'Enter' });
await waitFor(() =>
expect(dailyStatusesApi.update).toHaveBeenCalledWith(
STATUS_ID,
expect.objectContaining({ vitals: 'HR 80' })
)
);
// custom_fields must NOT be in the payload for a builtin field
const payload = dailyStatusesApi.update.mock.calls[0][1];
expect(payload).not.toHaveProperty('custom_fields');
});
});
// ── Prev / next day navigation ────────────────────────────────────────────────
describe('prev/next day navigation', () => {
it('shows no navigation buttons when there are no other days', async () => {
experimentsApi.getCalendar.mockResolvedValue({
field: FIELD_ID,
days: { [DATE_STR]: 2 },
});
render(<ExperimentDayView />);
await waitFor(() => expect(experimentsApi.getCalendar).toHaveBeenCalled());
expect(screen.queryByText(/← April/)).not.toBeInTheDocument();
expect(screen.queryByText(/April.*→/)).not.toBeInTheDocument();
});
it('shows a previous day button when an earlier day has data', async () => {
experimentsApi.getCalendar.mockResolvedValue({
field: FIELD_ID,
days: { '2026-04-22': 1, [DATE_STR]: 2 },
});
render(<ExperimentDayView />);
await waitFor(() => screen.getByText(/April 22, 2026/));
expect(screen.getByText(/← April 22, 2026/)).toBeInTheDocument();
});
it('shows a next day button when a later day has data', async () => {
experimentsApi.getCalendar.mockResolvedValue({
field: FIELD_ID,
days: { [DATE_STR]: 2, '2026-04-24': 1 },
});
render(<ExperimentDayView />);
await waitFor(() => screen.getByText(/April 24, 2026/));
expect(screen.getByText(/April 24, 2026 →/)).toBeInTheDocument();
});
it('shows both prev and next buttons when surrounded by days with data', async () => {
experimentsApi.getCalendar.mockResolvedValue({
field: FIELD_ID,
days: { '2026-04-21': 1, [DATE_STR]: 2, '2026-04-25': 3 },
});
render(<ExperimentDayView />);
await waitFor(() => screen.getByText(/April 21, 2026/));
expect(screen.getByText(/← April 21, 2026/)).toBeInTheDocument();
expect(screen.getByText(/April 25, 2026 →/)).toBeInTheDocument();
});
it('navigates to the previous day with the field param preserved', async () => {
experimentsApi.getCalendar.mockResolvedValue({
field: FIELD_ID,
days: { '2026-04-22': 1, [DATE_STR]: 2 },
});
render(<ExperimentDayView />);
await waitFor(() => screen.getByText(/← April 22, 2026/));
fireEvent.click(screen.getByText(/← April 22, 2026/));
expect(mockNavigate).toHaveBeenCalledWith(
`/experiments/${EXP_ID}/day/2026-04-22?field=${FIELD_ID}`
);
});
it('navigates to the next day with the field param preserved', async () => {
experimentsApi.getCalendar.mockResolvedValue({
field: FIELD_ID,
days: { [DATE_STR]: 2, '2026-04-24': 1 },
});
render(<ExperimentDayView />);
await waitFor(() => screen.getByText(/April 24, 2026 →/));
fireEvent.click(screen.getByText(/April 24, 2026 →/));
expect(mockNavigate).toHaveBeenCalledWith(
`/experiments/${EXP_ID}/day/2026-04-24?field=${FIELD_ID}`
);
});
it('only shows adjacent days, not all days (prev is immediately before in sorted list)', async () => {
experimentsApi.getCalendar.mockResolvedValue({
field: FIELD_ID,
days: { '2026-04-10': 1, '2026-04-20': 2, [DATE_STR]: 3, '2026-04-30': 1 },
});
render(<ExperimentDayView />);
await waitFor(() => screen.getByText(/April 20, 2026/));
// Prev should be April 20 (immediately before), not April 10
expect(screen.getByText(/← April 20, 2026/)).toBeInTheDocument();
expect(screen.queryByText(/April 10/)).not.toBeInTheDocument();
// Next should be April 30 (immediately after)
expect(screen.getByText(/April 30, 2026 →/)).toBeInTheDocument();
});
});
+105
View File
@@ -0,0 +1,105 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ExperimentForm from '../src/components/ExperimentForm';
import * as client from '../src/api/client';
jest.mock('../src/api/client', () => ({
experimentsApi: {
create: jest.fn(),
update: jest.fn(),
},
}));
const onSuccess = jest.fn();
const onCancel = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
describe('ExperimentForm', () => {
it('renders title input and submit button', () => {
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
expect(screen.getByLabelText(/experiment title/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /create experiment/i })).toBeInTheDocument();
});
it('shows validation error when submitting empty title', async () => {
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
fireEvent.click(screen.getByRole('button', { name: /create experiment/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/title is required/i);
expect(client.experimentsApi.create).not.toHaveBeenCalled();
});
it('shows error when title exceeds 255 chars', async () => {
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
await userEvent.type(screen.getByLabelText(/experiment title/i), 'x'.repeat(256));
fireEvent.click(screen.getByRole('button', { name: /create experiment/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/255/i);
expect(client.experimentsApi.create).not.toHaveBeenCalled();
});
it('calls create API with trimmed title and calls onSuccess', async () => {
const experiment = { id: 'abc-123', title: 'New Study' };
client.experimentsApi.create.mockResolvedValue(experiment);
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
await userEvent.type(screen.getByLabelText(/experiment title/i), 'New Study');
fireEvent.click(screen.getByRole('button', { name: /create experiment/i }));
await waitFor(() => expect(client.experimentsApi.create).toHaveBeenCalledWith({ title: 'New Study' }));
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(experiment));
});
it('shows "Save Changes" button for existing experiment', () => {
render(
<ExperimentForm
existing={{ id: 'abc', title: 'Old Title' }}
onSuccess={onSuccess}
onCancel={onCancel}
/>
);
expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument();
expect(screen.getByDisplayValue('Old Title')).toBeInTheDocument();
});
it('calls update API for existing experiment', async () => {
const updated = { id: 'abc', title: 'Updated Title' };
client.experimentsApi.update.mockResolvedValue(updated);
render(
<ExperimentForm
existing={{ id: 'abc', title: 'Old Title' }}
onSuccess={onSuccess}
onCancel={onCancel}
/>
);
const input = screen.getByDisplayValue('Old Title');
await userEvent.clear(input);
await userEvent.type(input, 'Updated Title');
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
await waitFor(() =>
expect(client.experimentsApi.update).toHaveBeenCalledWith('abc', { title: 'Updated Title' })
);
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(updated));
});
it('calls onCancel when Cancel is clicked', () => {
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(onCancel).toHaveBeenCalled();
});
it('shows API error when create fails', async () => {
client.experimentsApi.create.mockRejectedValue({ message: 'Server error', details: null });
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
await userEvent.type(screen.getByLabelText(/experiment title/i), 'Test');
fireEvent.click(screen.getByRole('button', { name: /create experiment/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/server error/i);
});
});
+375
View File
@@ -0,0 +1,375 @@
import React from 'react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import FileDropZone from '../src/components/FileDropZone';
import { sessionFilesApi } from '../src/api/client';
jest.mock('../src/api/client', () => ({
sessionFilesApi: {
list: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
},
}));
// ── Fixtures ───────────────────────────────────────────────────────────────────
const DATE = '2026-04-23';
const ANIMALS = [
{ id: 'a1', animal_name: 'Rat 2852', animal_id_string: '2852' },
{ id: 'a2', animal_name: 'Rat 3076', animal_id_string: '3076' },
{ id: 'a3', animal_name: 'Rat 3077', animal_id_string: '3077' },
];
const STATUS_A1 = { id: 's-a1', animal_id: 'a1', date: `${DATE}T00:00:00.000Z` };
const STATUS_A2 = { id: 's-a2', animal_id: 'a2', date: `${DATE}T00:00:00.000Z` };
const STATUS_A3 = { id: 's-a3', animal_id: 'a3', date: `${DATE}T00:00:00.000Z` };
const ROWS = [
{ animal: ANIMALS[0], status: STATUS_A1 },
{ animal: ANIMALS[1], status: STATUS_A2 },
{ animal: ANIMALS[2], status: STATUS_A3 },
];
function makeFile(name, size = 1024, type = '') {
return new File(['x'], name, { type, lastModified: Date.now() });
}
function makeSavedFile(id, statusId, filename, fileSize = '10240') {
return { id, daily_status_id: statusId, filename, file_size: fileSize, file_type: 'CSV', last_modified: null, created_at: new Date().toISOString() };
}
function dropFiles(files) {
const zone = screen.getByTestId('drop-zone');
fireEvent.dragEnter(zone, { dataTransfer: { files } });
fireEvent.dragOver(zone, { dataTransfer: { files } });
fireEvent.drop(zone, { dataTransfer: { files } });
}
beforeEach(() => {
jest.clearAllMocks();
sessionFilesApi.list.mockResolvedValue([]);
sessionFilesApi.create.mockResolvedValue([]);
sessionFilesApi.delete.mockResolvedValue();
});
// ── Rendering ──────────────────────────────────────────────────────────────────
describe('rendering', () => {
it('renders the drop zone', () => {
render(<FileDropZone rows={ROWS} date={DATE} />);
expect(screen.getByTestId('drop-zone')).toBeInTheDocument();
expect(screen.getByText(/drag.*drop session files/i)).toBeInTheDocument();
});
it('shows naming convention hint using first animal id', () => {
render(<FileDropZone rows={ROWS} date={DATE} />);
expect(screen.getByText(/2026-04-23_2852/)).toBeInTheDocument();
});
it('loads saved files on mount for each status', async () => {
sessionFilesApi.list.mockResolvedValue([]);
render(<FileDropZone rows={ROWS} date={DATE} />);
await waitFor(() => {
expect(sessionFilesApi.list).toHaveBeenCalledWith('s-a1');
expect(sessionFilesApi.list).toHaveBeenCalledWith('s-a2');
expect(sessionFilesApi.list).toHaveBeenCalledWith('s-a3');
});
});
it('shows saved files loaded from the API', async () => {
sessionFilesApi.list.mockImplementation((id) =>
id === 's-a1'
? Promise.resolve([makeSavedFile('f1', 's-a1', `${DATE}_2852_trials.csv`)])
: Promise.resolve([])
);
render(<FileDropZone rows={ROWS} date={DATE} />);
await waitFor(() =>
expect(screen.getByText(`${DATE}_2852_trials.csv`)).toBeInTheDocument()
);
expect(screen.getByTestId('saved-files')).toBeInTheDocument();
});
});
// ── Drag state ─────────────────────────────────────────────────────────────────
describe('drag interaction', () => {
it('highlights drop zone on dragenter', () => {
render(<FileDropZone rows={ROWS} date={DATE} />);
const zone = screen.getByTestId('drop-zone');
fireEvent.dragEnter(zone, { dataTransfer: { files: [] } });
expect(zone.className).toMatch(/border-blue-400/);
expect(screen.getByText(/release to register/i)).toBeInTheDocument();
});
it('removes highlight on dragleave', () => {
render(<FileDropZone rows={ROWS} date={DATE} />);
const zone = screen.getByTestId('drop-zone');
fireEvent.dragEnter(zone, { dataTransfer: { files: [] } });
fireEvent.dragLeave(zone, { dataTransfer: { files: [] } });
expect(zone.className).not.toMatch(/border-blue-400/);
});
});
// ── File matching ──────────────────────────────────────────────────────────────
describe('filename-to-subject matching', () => {
it('matches files containing the animal_id_string to the correct subject', async () => {
render(<FileDropZone rows={ROWS} date={DATE} />);
dropFiles([
makeFile(`${DATE}_2852_ephys_raw.bin`, 10_000_000_000),
makeFile(`${DATE}_3076_trials.csv`, 10_000_000),
]);
await waitFor(() => screen.getByTestId('drop-preview'));
expect(screen.getByText(/Rat 2852/)).toBeInTheDocument();
expect(screen.getByText(/Rat 3076/)).toBeInTheDocument();
expect(screen.getByText(`${DATE}_2852_ephys_raw.bin`)).toBeInTheDocument();
expect(screen.getByText(`${DATE}_3076_trials.csv`)).toBeInTheDocument();
});
it('shows unmatched section for files with no subject ID in name', async () => {
render(<FileDropZone rows={ROWS} date={DATE} />);
dropFiles([makeFile('unknown_file.csv', 1024)]);
await waitFor(() => screen.getByTestId('drop-preview'));
expect(screen.getByText(/unmatched/i)).toBeInTheDocument();
expect(screen.getByText('unknown_file.csv')).toBeInTheDocument();
});
it('does not match a partial numeric overlap (2852 should not match 28520)', async () => {
render(<FileDropZone rows={ROWS} date={DATE} />);
dropFiles([makeFile('session_28520_data.csv', 1024)]);
await waitFor(() => screen.getByTestId('drop-preview'));
expect(screen.getByText(/unmatched/i)).toBeInTheDocument();
});
// ── animal_name matching ───────────────────────────────────────────────────
it('matches by animal_name when the name appears literally in the filename', async () => {
const animal = { id: 'b1', animal_name: 'Homer', animal_id_string: null };
const row = { animal, status: { id: 's-b1', animal_id: 'b1', date: `${DATE}T00:00:00.000Z` } };
render(<FileDropZone rows={[row]} date={DATE} />);
dropFiles([makeFile(`${DATE}_Homer_session.csv`)]);
await waitFor(() => screen.getByTestId('drop-preview'));
expect(screen.queryByText(/unmatched/i)).not.toBeInTheDocument();
expect(screen.getByText(`${DATE}_Homer_session.csv`)).toBeInTheDocument();
});
it('matches multi-word animal_name with underscores as separator in filename', async () => {
const animal = { id: 'b1', animal_name: 'Rat A', animal_id_string: null };
const row = { animal, status: { id: 's-b1', animal_id: 'b1', date: `${DATE}T00:00:00.000Z` } };
render(<FileDropZone rows={[row]} date={DATE} />);
dropFiles([makeFile(`${DATE}_Rat_A_trials.csv`)]);
await waitFor(() => screen.getByTestId('drop-preview'));
expect(screen.queryByText(/unmatched/i)).not.toBeInTheDocument();
});
it('matches multi-word animal_name with dashes as separator in filename', async () => {
const animal = { id: 'b1', animal_name: 'Rat A', animal_id_string: null };
const row = { animal, status: { id: 's-b1', animal_id: 'b1', date: `${DATE}T00:00:00.000Z` } };
render(<FileDropZone rows={[row]} date={DATE} />);
dropFiles([makeFile(`${DATE}-Rat-A-session.bin`)]);
await waitFor(() => screen.getByTestId('drop-preview'));
expect(screen.queryByText(/unmatched/i)).not.toBeInTheDocument();
});
it('matches by animal_name even when animal_id_string is also set (OR logic)', async () => {
const animal = { id: 'b1', animal_name: 'Rat A', animal_id_string: '2852' };
const row = { animal, status: { id: 's-b1', animal_id: 'b1', date: `${DATE}T00:00:00.000Z` } };
render(<FileDropZone rows={[row]} date={DATE} />);
// File uses name, not ID
dropFiles([makeFile(`${DATE}_Rat_A_ephys.bin`)]);
await waitFor(() => screen.getByTestId('drop-preview'));
expect(screen.queryByText(/unmatched/i)).not.toBeInTheDocument();
});
it('does not match a partial name overlap (Rat should not match Rationale)', async () => {
const animal = { id: 'b1', animal_name: 'Rat', animal_id_string: null };
const row = { animal, status: { id: 's-b1', animal_id: 'b1', date: `${DATE}T00:00:00.000Z` } };
render(<FileDropZone rows={[row]} date={DATE} />);
dropFiles([makeFile('Rationale_study_data.csv')]);
await waitFor(() => screen.getByTestId('drop-preview'));
expect(screen.getByText(/unmatched/i)).toBeInTheDocument();
});
it('shows warning for subjects with no matched files', async () => {
render(<FileDropZone rows={ROWS} date={DATE} />);
// Only drop file for 2852 — 3076 and 3077 get no match
dropFiles([makeFile(`${DATE}_2852_trials.csv`, 1024)]);
await waitFor(() => screen.getByTestId('drop-preview'));
expect(screen.getByText(/no files matched/i)).toBeInTheDocument();
expect(screen.getByText('3076')).toBeInTheDocument();
expect(screen.getByText('3077')).toBeInTheDocument();
});
it('matches all four files per subject correctly', async () => {
render(<FileDropZone rows={[{ animal: ANIMALS[0], status: STATUS_A1 }]} date={DATE} />);
dropFiles([
makeFile(`${DATE}_2852_ephys_raw.bin`, 10_000_000_000),
makeFile(`${DATE}_2852_kilosort.h5`, 10_000_000_000),
makeFile(`${DATE}_2852_trials.csv`, 10_000_000),
makeFile(`${DATE}_2852_behavior_log.csv`, 10_000_000),
]);
await waitFor(() => screen.getByTestId('drop-preview'));
// Save button label is the authoritative "all 4 matched" signal
expect(screen.getByTestId('save-btn')).toHaveTextContent('Register 4 files');
});
it('handles multiple subjects with 4 files each simultaneously', async () => {
render(<FileDropZone rows={ROWS} date={DATE} />);
dropFiles([
makeFile(`${DATE}_2852_ephys_raw.bin`, 10_000_000_000),
makeFile(`${DATE}_2852_kilosort.h5`, 10_000_000_000),
makeFile(`${DATE}_2852_trials.csv`, 10_000_000),
makeFile(`${DATE}_2852_behavior_log.csv`, 10_000_000),
makeFile(`${DATE}_3076_ephys_raw.bin`, 10_000_000_000),
makeFile(`${DATE}_3076_kilosort.h5`, 10_000_000_000),
makeFile(`${DATE}_3076_trials.csv`, 10_000_000),
makeFile(`${DATE}_3076_behavior_log.csv`, 10_000_000),
]);
await waitFor(() => screen.getByTestId('drop-preview'));
const saveBtn = screen.getByTestId('save-btn');
expect(saveBtn.textContent).toMatch(/8 files/);
});
});
// ── Remove before saving ───────────────────────────────────────────────────────
describe('remove files before saving', () => {
it('removes a matched file when ✕ is clicked', async () => {
render(<FileDropZone rows={ROWS} date={DATE} />);
dropFiles([makeFile(`${DATE}_2852_trials.csv`, 1024)]);
await waitFor(() => screen.getByText(`${DATE}_2852_trials.csv`));
fireEvent.click(screen.getAllByText('✕')[0]);
await waitFor(() =>
expect(screen.queryByText(`${DATE}_2852_trials.csv`)).not.toBeInTheDocument()
);
});
});
// ── Saving ─────────────────────────────────────────────────────────────────────
describe('saving metadata', () => {
it('calls sessionFilesApi.create with correct payload including matching metadata', async () => {
const file = makeFile(`${DATE}_2852_trials.csv`);
const savedFile = makeSavedFile('f1', 's-a1', file.name, String(file.size));
sessionFilesApi.create.mockResolvedValue([savedFile]);
render(<FileDropZone rows={ROWS} date={DATE} />);
dropFiles([file]);
await waitFor(() => screen.getByTestId('save-btn'));
fireEvent.click(screen.getByTestId('save-btn'));
await waitFor(() =>
expect(sessionFilesApi.create).toHaveBeenCalledWith(
's-a1',
expect.arrayContaining([
expect.objectContaining({
filename: `${DATE}_2852_trials.csv`,
file_size: file.size,
file_type: 'CSV',
matched_identifier: '2852',
animal_id_string_snap: '2852',
animal_name_snap: 'Rat 2852',
}),
])
)
);
});
it('sets matched_identifier to animal_name when file matched by name', async () => {
const animal = { id: 'b1', animal_name: 'Rat A', animal_id_string: null };
const row = { animal, status: { id: 's-b1', animal_id: 'b1', date: `${DATE}T00:00:00.000Z` } };
sessionFilesApi.create.mockResolvedValue([]);
render(<FileDropZone rows={[row]} date={DATE} />);
dropFiles([makeFile(`${DATE}_Rat_A_trials.csv`)]);
await waitFor(() => screen.getByTestId('save-btn'));
fireEvent.click(screen.getByTestId('save-btn'));
await waitFor(() =>
expect(sessionFilesApi.create).toHaveBeenCalledWith(
's-b1',
expect.arrayContaining([
expect.objectContaining({
matched_identifier: 'Rat A',
animal_id_string_snap: null,
animal_name_snap: 'Rat A',
}),
])
)
);
});
it('does not call create for subjects with no matched files', async () => {
sessionFilesApi.create.mockResolvedValue([]);
render(<FileDropZone rows={ROWS} date={DATE} />);
// Only 2852 gets a file
dropFiles([makeFile(`${DATE}_2852_trials.csv`, 1024)]);
await waitFor(() => screen.getByTestId('save-btn'));
fireEvent.click(screen.getByTestId('save-btn'));
await waitFor(() => expect(sessionFilesApi.create).toHaveBeenCalledTimes(1));
expect(sessionFilesApi.create).toHaveBeenCalledWith('s-a1', expect.any(Array));
});
it('stores correct file_type classification from extension', async () => {
sessionFilesApi.create.mockResolvedValue([]);
render(<FileDropZone rows={[{ animal: ANIMALS[0], status: STATUS_A1 }]} date={DATE} />);
dropFiles([
makeFile(`${DATE}_2852_ephys.bin`, 10_000_000_000),
makeFile(`${DATE}_2852_spikes.h5`, 10_000_000_000),
makeFile(`${DATE}_2852_trials.csv`, 10_000_000),
makeFile(`${DATE}_2852_log.csv`, 10_000_000),
]);
await waitFor(() => screen.getByTestId('save-btn'));
fireEvent.click(screen.getByTestId('save-btn'));
await waitFor(() => expect(sessionFilesApi.create).toHaveBeenCalled());
const [, files] = sessionFilesApi.create.mock.calls[0];
expect(files.find((f) => f.filename.endsWith('.bin')).file_type).toBe('Binary');
expect(files.find((f) => f.filename.endsWith('.h5')).file_type).toBe('HDF5');
expect(files.filter((f) => f.filename.endsWith('.csv'))).toHaveLength(2);
files.filter((f) => f.filename.endsWith('.csv')).forEach((f) => {
expect(f.file_type).toBe('CSV');
});
});
it('clears the drop preview after a successful save', async () => {
sessionFilesApi.create.mockResolvedValue([makeSavedFile('f1', 's-a1', `${DATE}_2852_trials.csv`)]);
render(<FileDropZone rows={ROWS} date={DATE} />);
dropFiles([makeFile(`${DATE}_2852_trials.csv`, 1024)]);
await waitFor(() => screen.getByTestId('save-btn'));
fireEvent.click(screen.getByTestId('save-btn'));
await waitFor(() =>
expect(screen.queryByTestId('drop-preview')).not.toBeInTheDocument()
);
});
it('shows an error message when create fails', async () => {
sessionFilesApi.create.mockRejectedValue({ message: 'Server error' });
render(<FileDropZone rows={ROWS} date={DATE} />);
dropFiles([makeFile(`${DATE}_2852_trials.csv`, 1024)]);
await waitFor(() => screen.getByTestId('save-btn'));
fireEvent.click(screen.getByTestId('save-btn'));
await waitFor(() => screen.getByText(/server error/i));
});
});
// ── Deleting saved files ───────────────────────────────────────────────────────
describe('deleting saved files', () => {
it('removes a saved file when its ✕ is clicked', async () => {
sessionFilesApi.list.mockImplementation((id) =>
id === 's-a1'
? Promise.resolve([makeSavedFile('f1', 's-a1', `${DATE}_2852_trials.csv`)])
: Promise.resolve([])
);
render(<FileDropZone rows={ROWS} date={DATE} />);
await waitFor(() => screen.getByText(`${DATE}_2852_trials.csv`));
fireEvent.click(screen.getAllByText('✕')[0]);
await waitFor(() =>
expect(sessionFilesApi.delete).toHaveBeenCalledWith('f1')
);
await waitFor(() =>
expect(screen.queryByText(`${DATE}_2852_trials.csv`)).not.toBeInTheDocument()
);
});
});
+31
View File
@@ -0,0 +1,31 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Modal from '../src/components/ui/Modal';
describe('Modal component', () => {
it('does not render when isOpen is false', () => {
render(<Modal isOpen={false} onClose={() => {}} title="Test"><p>content</p></Modal>);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('renders title and children when open', () => {
render(<Modal isOpen={true} onClose={() => {}} title="My Modal"><p>Hello</p></Modal>);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('My Modal')).toBeInTheDocument();
expect(screen.getByText('Hello')).toBeInTheDocument();
});
it('calls onClose when close button is clicked', () => {
const onClose = jest.fn();
render(<Modal isOpen={true} onClose={onClose} title="T"><p>x</p></Modal>);
fireEvent.click(screen.getByLabelText(/close modal/i));
expect(onClose).toHaveBeenCalled();
});
it('calls onClose when Escape key is pressed', () => {
const onClose = jest.fn();
render(<Modal isOpen={true} onClose={onClose} title="T"><p>x</p></Modal>);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalled();
});
});
+104
View File
@@ -0,0 +1,104 @@
import React from 'react';
import { render, screen, fireEvent, within } from '@testing-library/react';
import { SubjectSidebar } from '../src/components/SubjectSidebar';
const LINES = [
{ id: 'a1', name: 'Mouse-A1', group: 'Group A', color: '#111' },
{ id: 'a2', name: 'Mouse-A2', group: 'Group A', color: '#222' },
{ id: 'b1', name: 'Mouse-B1', group: 'Group B', color: '#333' },
];
function setup(overrides = {}) {
const props = {
lines: LINES,
hiddenSubjects: new Set(),
onToggleSubject: jest.fn(),
onToggleGroup: jest.fn(),
onShowAll: jest.fn(),
onHighlight: jest.fn(),
...overrides,
};
render(<SubjectSidebar {...props} />);
return props;
}
it('renders each group header with its subject count', () => {
setup();
expect(screen.getByText('Group A')).toBeInTheDocument();
expect(screen.getByText('Group B')).toBeInTheDocument();
expect(screen.getByText('(2)')).toBeInTheDocument();
expect(screen.getByText('(1)')).toBeInTheDocument();
});
it('renders a subject checkbox per subject, checked when visible', () => {
setup({ hiddenSubjects: new Set(['a2']) });
expect(screen.getByLabelText('Mouse-A1').checked).toBe(true);
expect(screen.getByLabelText('Mouse-A2').checked).toBe(false);
});
it('calls onToggleSubject with the id when a subject checkbox is clicked', () => {
const { onToggleSubject } = setup();
fireEvent.click(screen.getByLabelText('Mouse-A1'));
expect(onToggleSubject).toHaveBeenCalledWith('a1');
});
it('shows the group checkbox as indeterminate when the group is mixed', () => {
setup({ hiddenSubjects: new Set(['a1']) });
const groupCheckbox = screen.getByLabelText(/Group A/);
expect(groupCheckbox.indeterminate).toBe(true);
expect(groupCheckbox.checked).toBe(false);
});
it('checks the group checkbox when all members are visible', () => {
setup();
expect(screen.getByLabelText(/Group A/).checked).toBe(true);
});
it('calls onToggleGroup with the group ids and hidden=true when an all-visible group is clicked', () => {
const { onToggleGroup } = setup();
fireEvent.click(screen.getByLabelText(/Group A/));
expect(onToggleGroup).toHaveBeenCalledWith(['a1', 'a2'], true);
});
it('shows the group checkbox unchecked and not indeterminate when all members are hidden', () => {
setup({ hiddenSubjects: new Set(['a1', 'a2']) });
const groupCheckbox = screen.getByLabelText(/Group A/);
expect(groupCheckbox.checked).toBe(false);
expect(groupCheckbox.indeterminate).toBe(false);
});
it('calls onToggleGroup with hidden=false when a fully-hidden group is clicked', () => {
const { onToggleGroup } = setup({ hiddenSubjects: new Set(['a1', 'a2']) });
fireEvent.click(screen.getByLabelText(/Group A/));
expect(onToggleGroup).toHaveBeenCalledWith(['a1', 'a2'], false);
});
it('hides the Show all link when nothing is hidden, shows it otherwise', () => {
const { rerender } = render(
<SubjectSidebar lines={LINES} hiddenSubjects={new Set()}
onToggleSubject={() => {}} onToggleGroup={() => {}}
onShowAll={() => {}} onHighlight={() => {}} />,
);
expect(screen.queryByText('Show all')).toBeNull();
rerender(
<SubjectSidebar lines={LINES} hiddenSubjects={new Set(['a1'])}
onToggleSubject={() => {}} onToggleGroup={() => {}}
onShowAll={() => {}} onHighlight={() => {}} />,
);
expect(screen.getByText('Show all')).toBeInTheDocument();
});
it('calls onShowAll when the Show all link is clicked', () => {
const { onShowAll } = setup({ hiddenSubjects: new Set(['a1']) });
fireEvent.click(screen.getByText('Show all'));
expect(onShowAll).toHaveBeenCalled();
});
it('calls onHighlight on subject row hover enter/leave', () => {
const { onHighlight } = setup();
const row = screen.getByLabelText('Mouse-A1').closest('label');
fireEvent.mouseEnter(row);
expect(onHighlight).toHaveBeenCalledWith('a1');
fireEvent.mouseLeave(row);
expect(onHighlight).toHaveBeenCalledWith(null);
});
+150
View File
@@ -0,0 +1,150 @@
import {
sortPayloadByValueDesc,
toggleInSet,
setIdsHidden,
groupVisibilityState,
groupLines,
serializePlotConfig,
readPlotConfig,
} from '../src/lib/crossSubjectChart';
describe('sortPayloadByValueDesc', () => {
it('sorts by value descending and drops null/undefined values', () => {
const payload = [
{ dataKey: 'a', value: 40 },
{ dataKey: 'b', value: null },
{ dataKey: 'c', value: 92 },
{ dataKey: 'd', value: 78 },
];
expect(sortPayloadByValueDesc(payload).map((p) => p.dataKey)).toEqual(['c', 'd', 'a']);
});
it('returns [] for non-array input', () => {
expect(sortPayloadByValueDesc(undefined)).toEqual([]);
});
it('does not mutate the input array', () => {
const payload = [{ dataKey: 'a', value: 1 }, { dataKey: 'b', value: 2 }];
sortPayloadByValueDesc(payload);
expect(payload.map((p) => p.dataKey)).toEqual(['a', 'b']);
});
});
describe('toggleInSet', () => {
it('adds a missing id and removes a present id, returning a new Set', () => {
const a = new Set(['x']);
const added = toggleInSet(a, 'y');
expect([...added].sort()).toEqual(['x', 'y']);
expect(added).not.toBe(a);
const removed = toggleInSet(added, 'x');
expect([...removed]).toEqual(['y']);
});
});
describe('setIdsHidden', () => {
it('adds all ids when hidden=true', () => {
expect([...setIdsHidden(new Set(['a']), ['b', 'c'], true)].sort()).toEqual(['a', 'b', 'c']);
});
it('removes all ids when hidden=false', () => {
expect([...setIdsHidden(new Set(['a', 'b', 'c']), ['b', 'c'], false)]).toEqual(['a']);
});
it('returns a new Set', () => {
const s = new Set();
expect(setIdsHidden(s, ['a'], true)).not.toBe(s);
});
});
describe('groupVisibilityState', () => {
it("returns 'all' when none of the ids are hidden", () => {
expect(groupVisibilityState(['a', 'b'], new Set())).toBe('all');
});
it("returns 'none' when all ids are hidden", () => {
expect(groupVisibilityState(['a', 'b'], new Set(['a', 'b']))).toBe('none');
});
it("returns 'some' when a subset is hidden", () => {
expect(groupVisibilityState(['a', 'b'], new Set(['a']))).toBe('some');
});
it("returns 'none' for an empty group", () => {
expect(groupVisibilityState([], new Set())).toBe('none');
});
});
describe('groupLines', () => {
it('groups by .group preserving first-seen group order and within-group order', () => {
const lines = [
{ id: '1', group: 'B' },
{ id: '2', group: 'A' },
{ id: '3', group: 'B' },
];
const out = groupLines(lines);
expect(out.map((g) => g.group)).toEqual(['B', 'A']);
expect(out[0].subjects.map((s) => s.id)).toEqual(['1', '3']);
});
it("uses '—' for lines with no group", () => {
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();
});
});
+361
View File
@@ -0,0 +1,361 @@
import {
parseCSVHeaders,
parseCSV,
getUniqueNoteValues,
runAnalysis,
extractNumber,
computeNumericSeries,
} from '../src/lib/csvAnalysis';
// ── Fixtures ───────────────────────────────────────────────────────────────────
// Minimal CSV mimicking the real experiment file structure
const FIXTURE_CSV = `timestamp,frame_number,frame_line_status,note
0.0,1,10,
1.0,2,10,f
2.0,3,10,s
3.0,4,10,s
4.0,5,10,f
5.0,6,10,
6.0,7,10,s
7.0,8,10,f
8.0,9,10,f
9.0,10,10,s`;
// Same data but with Windows-style line endings
const FIXTURE_CSV_CRLF = FIXTURE_CSV.replace(/\n/g, '\r\n');
// CSV with quoted field containing a comma
const FIXTURE_CSV_QUOTED = `timestamp,note\n1.0,"hello, world"\n2.0,plain`;
// Out-of-order timestamps (should be sorted)
const FIXTURE_UNSORTED = `timestamp,note
3.0,s
1.0,f
2.0,s`;
const CLASSIFICATIONS = { f: 'Failure', s: 'Success' };
// ── parseCSVHeaders ────────────────────────────────────────────────────────────
describe('parseCSVHeaders', () => {
test('returns column names from first line', () => {
expect(parseCSVHeaders(FIXTURE_CSV)).toEqual([
'timestamp', 'frame_number', 'frame_line_status', 'note',
]);
});
test('handles CRLF line endings', () => {
expect(parseCSVHeaders(FIXTURE_CSV_CRLF)).toEqual([
'timestamp', 'frame_number', 'frame_line_status', 'note',
]);
});
test('handles single-line CSV (no newline)', () => {
expect(parseCSVHeaders('a,b,c')).toEqual(['a', 'b', 'c']);
});
});
// ── parseCSV ──────────────────────────────────────────────────────────────────
describe('parseCSV', () => {
test('returns headers and rows', () => {
const { headers, rows } = parseCSV(FIXTURE_CSV);
expect(headers).toEqual(['timestamp', 'frame_number', 'frame_line_status', 'note']);
expect(rows).toHaveLength(10);
});
test('row values are keyed by header name', () => {
const { rows } = parseCSV(FIXTURE_CSV);
expect(rows[0]).toEqual({ timestamp: '0.0', frame_number: '1', frame_line_status: '10', note: '' });
expect(rows[1]).toEqual({ timestamp: '1.0', frame_number: '2', frame_line_status: '10', note: 'f' });
});
test('handles CRLF line endings', () => {
const { rows } = parseCSV(FIXTURE_CSV_CRLF);
expect(rows).toHaveLength(10);
expect(rows[1].note).toBe('f');
});
test('handles quoted fields containing commas', () => {
const { rows } = parseCSV(FIXTURE_CSV_QUOTED);
expect(rows[0].note).toBe('hello, world');
expect(rows[1].note).toBe('plain');
});
test('skips blank lines', () => {
const csv = 'a,b\n1,2\n\n3,4\n';
const { rows } = parseCSV(csv);
expect(rows).toHaveLength(2);
});
});
// ── getUniqueNoteValues ────────────────────────────────────────────────────────
describe('getUniqueNoteValues', () => {
test('returns sorted unique non-empty values', () => {
const { rows } = parseCSV(FIXTURE_CSV);
expect(getUniqueNoteValues(rows, 'note')).toEqual(['f', 's']);
});
test('ignores empty strings', () => {
const rows = [{ note: '' }, { note: 'a' }, { note: '' }, { note: 'b' }];
expect(getUniqueNoteValues(rows, 'note')).toEqual(['a', 'b']);
});
test('trims whitespace before deduplication', () => {
const rows = [{ note: ' a ' }, { note: 'a' }, { note: 'b' }];
expect(getUniqueNoteValues(rows, 'note')).toEqual(['a', 'b']);
});
test('returns empty array when no non-empty notes', () => {
const rows = [{ note: '' }, { note: '' }];
expect(getUniqueNoteValues(rows, 'note')).toEqual([]);
});
});
// ── runAnalysis ────────────────────────────────────────────────────────────────
describe('runAnalysis', () => {
let result;
beforeEach(() => {
const { rows } = parseCSV(FIXTURE_CSV);
result = runAnalysis(rows, 'timestamp', 'note', CLASSIFICATIONS);
});
// Fixture note sequence (sorted by timestamp, empty rows excluded):
// t=1 f, t=2 s, t=3 s, t=4 f, t=6 s, t=7 f, t=8 f, t=9 s
// Categories: F S S F S F F S
test('totalNoteRows counts only rows with non-empty note', () => {
expect(result.totalNoteRows).toBe(8);
});
test('sequence preserves order and maps to categories', () => {
const notes = result.sequence.map(r => r.note);
expect(notes).toEqual(['f', 's', 's', 'f', 's', 'f', 'f', 's']);
const cats = result.sequence.map(r => r.category);
expect(cats).toEqual(['Failure', 'Success', 'Success', 'Failure', 'Success', 'Failure', 'Failure', 'Success']);
});
test('categoryCounts totals are correct', () => {
expect(result.categoryCounts['Failure'].total).toBe(4);
expect(result.categoryCounts['Success'].total).toBe(4);
});
test('categoryCounts byNote breakdown is correct', () => {
expect(result.categoryCounts['Failure'].byNote).toEqual({ f: 4 });
expect(result.categoryCounts['Success'].byNote).toEqual({ s: 4 });
});
test('runs (run-length encoding) are correct', () => {
// F S S F S F F S → F×1, S×2, F×1, S×1, F×2, S×1
expect(result.runs).toEqual([
{ category: 'Failure', length: 1 },
{ category: 'Success', length: 2 },
{ category: 'Failure', length: 1 },
{ category: 'Success', length: 1 },
{ category: 'Failure', length: 2 },
{ category: 'Success', length: 1 },
]);
});
test('consecutiveStats totalRuns', () => {
expect(result.consecutiveStats['Failure'].totalRuns).toBe(3);
expect(result.consecutiveStats['Success'].totalRuns).toBe(3);
});
test('consecutiveStats maxRun', () => {
expect(result.consecutiveStats['Failure'].maxRun).toBe(2);
expect(result.consecutiveStats['Success'].maxRun).toBe(2);
});
test('consecutiveStats avgRun', () => {
// Failure runs: [1,1,2] → avg = 4/3
expect(result.consecutiveStats['Failure'].avgRun).toBeCloseTo(4 / 3);
// Success runs: [2,1,1] → avg = 4/3
expect(result.consecutiveStats['Success'].avgRun).toBeCloseTo(4 / 3);
});
test('consecutiveStats runLengths array', () => {
expect(result.consecutiveStats['Failure'].runLengths).toEqual([1, 1, 2]);
expect(result.consecutiveStats['Success'].runLengths).toEqual([2, 1, 1]);
});
});
describe('runAnalysis — sorting', () => {
test('sorts rows by timestamp before building sequence', () => {
const { rows } = parseCSV(FIXTURE_UNSORTED);
// Input order: s(3), f(1), s(2) — sorted order: f(1), s(2), s(3)
const result = runAnalysis(rows, 'timestamp', 'note', CLASSIFICATIONS);
expect(result.sequence.map(r => r.note)).toEqual(['f', 's', 's']);
});
});
describe('runAnalysis — unclassified notes', () => {
test('unclassified notes appear in sequence with null category', () => {
const { rows } = parseCSV(FIXTURE_CSV);
// Only classify 'f'; leave 's' unclassified
const result = runAnalysis(rows, 'timestamp', 'note', { f: 'Failure' });
const nullCats = result.sequence.filter(r => r.category === null);
expect(nullCats.length).toBe(4); // 4 's' notes
});
test('unclassified notes are excluded from categoryCounts', () => {
const { rows } = parseCSV(FIXTURE_CSV);
const result = runAnalysis(rows, 'timestamp', 'note', { f: 'Failure' });
expect(result.categoryCounts['Success']).toBeUndefined();
});
test('unclassified notes do not break or extend runs', () => {
// Sequence with 'x' unclassified: f x f → should still be one Failure run of 2
const csv = 'ts,note\n1.0,f\n2.0,x\n3.0,f';
const { rows } = parseCSV(csv);
// 'x' is unclassified, 'f' → Failure
// After skipping 'x': f, f → one run of Failure×2
const result = runAnalysis(rows, 'ts', 'note', { f: 'Failure' });
expect(result.runs).toEqual([{ category: 'Failure', length: 2 }]);
});
});
describe('runAnalysis — multiple note values per category', () => {
test('groups multiple note values under one category', () => {
// The user's example: ss and s both belong to Success
const csv = 'ts,note\n1.0,ss\n2.0,s\n3.0,s';
const { rows } = parseCSV(csv);
const result = runAnalysis(rows, 'ts', 'note', { ss: 'Success', s: 'Success' });
expect(result.categoryCounts['Success'].total).toBe(3);
expect(result.categoryCounts['Success'].byNote).toEqual({ ss: 1, s: 2 });
});
test('run-length encoding with multiple note values in same category', () => {
const csv = 'ts,note\n1.0,ss\n2.0,s\n3.0,f';
const { rows } = parseCSV(csv);
const result = runAnalysis(rows, 'ts', 'note', { ss: 'Success', s: 'Success', f: 'Failure' });
// ss and s are both Success → run of 2, then Failure run of 1
expect(result.runs).toEqual([
{ category: 'Success', length: 2 },
{ category: 'Failure', length: 1 },
]);
});
});
describe('runAnalysis — edge cases', () => {
test('empty rows returns zero totals', () => {
const result = runAnalysis([], 'timestamp', 'note', CLASSIFICATIONS);
expect(result.totalNoteRows).toBe(0);
expect(result.sequence).toEqual([]);
expect(result.runs).toEqual([]);
expect(result.categoryCounts).toEqual({});
expect(result.consecutiveStats).toEqual({});
});
test('all notes unclassified', () => {
const { rows } = parseCSV(FIXTURE_CSV);
const result = runAnalysis(rows, 'timestamp', 'note', {});
expect(result.categoryCounts).toEqual({});
expect(result.runs).toEqual([]);
});
test('single note row', () => {
const csv = 'ts,note\n1.0,f';
const { rows } = parseCSV(csv);
const result = runAnalysis(rows, 'ts', 'note', { f: 'Failure' });
expect(result.totalNoteRows).toBe(1);
expect(result.runs).toEqual([{ category: 'Failure', length: 1 }]);
expect(result.consecutiveStats['Failure'].maxRun).toBe(1);
expect(result.consecutiveStats['Failure'].avgRun).toBe(1);
});
test('all same category', () => {
const csv = 'ts,note\n1.0,f\n2.0,f\n3.0,f';
const { rows } = parseCSV(csv);
const result = runAnalysis(rows, 'ts', 'note', { f: 'Failure' });
expect(result.runs).toEqual([{ category: 'Failure', length: 3 }]);
expect(result.consecutiveStats['Failure'].maxRun).toBe(3);
expect(result.consecutiveStats['Failure'].totalRuns).toBe(1);
});
});
describe('extractNumber', () => {
test('extracts positive decimal from prefixed string', () => {
expect(extractNumber('L25.5')).toBe(25.5);
});
test('extracts negative integer with letter prefix', () => {
expect(extractNumber('R-12')).toBe(-12);
});
test('extracts integer from underscore-separated label', () => {
expect(extractNumber('trial_007')).toBe(7);
});
test('does not parse scientific notation as one number', () => {
// Accepted limitation: regex matches "3.2", strips "e2"
expect(extractNumber('3.2e2')).toBe(3.2);
});
test('strips trailing units', () => {
expect(extractNumber('42 ms')).toBe(42);
});
test('returns null for purely alphabetic string', () => {
expect(extractNumber('abc')).toBeNull();
});
test('returns null for empty string', () => {
expect(extractNumber('')).toBeNull();
});
test('returns null for null/undefined', () => {
expect(extractNumber(null)).toBeNull();
expect(extractNumber(undefined)).toBeNull();
});
});
describe('computeNumericSeries', () => {
const rows = [
{ frame: '1', timestamp: '0.0', note: 'L25' },
{ frame: '3', timestamp: '2.0', note: 'L30.5' },
{ frame: '2', timestamp: '1.0', note: 'L40' },
{ frame: '4', timestamp: '3.0', note: 'F' },
{ frame: '5', timestamp: '4.0', note: 'abc' }, // bucket-member but no number
{ frame: 'x', timestamp: '5.0', note: 'L99' }, // bad x
{ frame: '6', timestamp: '6.0', note: '' }, // not in bucket
];
const bucket = { name: 'Lick latency', type: 'numerical', notes: ['L25', 'L30.5', 'L40', 'abc', 'L99'] };
test('returns sorted points with average and counts', () => {
const out = computeNumericSeries(rows, 'frame', 'note', bucket);
expect(out.points).toEqual([
{ x: 1, value: 25, raw: 'L25' },
{ x: 2, value: 40, raw: 'L40' },
{ x: 3, value: 30.5, raw: 'L30.5' },
]);
expect(out.avg).toBeCloseTo((25 + 40 + 30.5) / 3);
expect(out.skipped).toBe(2); // 'abc' (no number) + bad x
expect(out.total).toBe(5); // bucket-member rows
expect(out.xLabel).toBe('frame');
});
test('falls back to timestamp x-column when frame is empty string', () => {
const out = computeNumericSeries(rows, 'timestamp', 'note', bucket);
expect(out.xLabel).toBe('timestamp');
expect(out.points[0].x).toBe(0);
});
test('returns avg=null and empty points when nothing plottable', () => {
const onlyBad = [
{ frame: '1', note: 'abc' },
];
const b = { name: 'Z', type: 'numerical', notes: ['abc'] };
const out = computeNumericSeries(onlyBad, 'frame', 'note', b);
expect(out.points).toEqual([]);
expect(out.avg).toBeNull();
expect(out.skipped).toBe(1);
expect(out.total).toBe(1);
});
test('returns total=0 when bucket notes match nothing in rows', () => {
const b = { name: 'Empty', type: 'numerical', notes: ['nope'] };
const out = computeNumericSeries(rows, 'frame', 'note', b);
expect(out.total).toBe(0);
expect(out.points).toEqual([]);
expect(out.avg).toBeNull();
});
});
+157
View File
@@ -0,0 +1,157 @@
import { extractValue } from '../src/lib/dataExport';
import { listExportableParams } from '../src/lib/dataExport';
const status = {
vitals: 'ok',
custom_fields: { 'f-weight': 250 },
analysis_summary: { total: 12, success_rate: 0.83, counts: { Success: 10, Failure: 2 } },
};
describe('extractValue', () => {
it('reads a builtin field via statusKey', () => {
expect(extractValue(status, { kind: 'builtin', statusKey: 'vitals' })).toBe('ok');
});
it('reads a custom field via fieldId', () => {
expect(extractValue(status, { kind: 'custom', fieldId: 'f-weight' })).toBe(250);
});
it('reads session-metric total', () => {
expect(extractValue(status, { kind: 'total' })).toBe(12);
});
it('reads session-metric success_rate as a raw 0-1 fraction', () => {
expect(extractValue(status, { kind: 'success_rate' })).toBe(0.83);
});
it('reads a dynamic count category', () => {
expect(extractValue(status, { kind: 'category', category: 'Failure' })).toBe(2);
});
it('returns null when analysis_summary is absent', () => {
expect(extractValue({ custom_fields: {} }, { kind: 'total' })).toBeNull();
expect(extractValue({}, { kind: 'category', category: 'Success' })).toBeNull();
});
it('returns null for a missing custom field', () => {
expect(extractValue({ custom_fields: {} }, { kind: 'custom', fieldId: 'nope' })).toBeNull();
expect(extractValue({}, { kind: 'builtin', statusKey: 'notes' })).toBeNull();
});
});
const dailyTemplate = [
{ fieldId: 'b-vitals', key: 'vitals', label: 'Vitals', builtin: true, active: true },
{ fieldId: 'c-weight', key: 'weight', label: 'Weight (g)', builtin: false, active: false },
];
const statuses = [
{ analysis_summary: { counts: { Success: 1, Failure: 0 } } },
{ analysis_summary: { counts: { Success: 2, Other: 1 } } },
{ analysis_summary: null },
];
describe('listExportableParams', () => {
const params = listExportableParams(dailyTemplate, statuses);
it('starts with the two fixed session metrics', () => {
expect(params.slice(0, 2)).toEqual([
expect.objectContaining({ id: '__total__', label: 'Total attempts', group: 'metrics', kind: 'total' }),
expect.objectContaining({ id: '__success_rate__', label: 'Success rate', group: 'metrics', kind: 'success_rate' }),
]);
});
it('adds one metrics entry per dynamic count category, sorted', () => {
const cats = params.filter((p) => p.kind === 'category').map((p) => p.label);
expect(cats).toEqual(['Failure', 'Other', 'Success']);
});
it('includes every template field (incl. inactive) in the daily group', () => {
const daily = params.filter((p) => p.group === 'daily');
expect(daily).toEqual([
expect.objectContaining({ label: 'Vitals', kind: 'builtin', statusKey: 'vitals' }),
expect.objectContaining({ label: 'Weight (g)', kind: 'custom', fieldId: 'c-weight' }),
]);
});
it('gives every param a unique id', () => {
const ids = params.map((p) => p.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('tolerates empty/missing inputs', () => {
expect(listExportableParams(undefined, undefined).length).toBe(2); // just the two fixed metrics
});
});
import { buildMatrix } from '../src/lib/dataExport';
const animals = [
{ id: 'a2', animal_name: 'Beta', animal_id_string: 'R-002' },
{ id: 'a1', animal_name: 'Alpha', animal_id_string: 'R-001' },
];
const totalParam = { kind: 'total' };
describe('buildMatrix', () => {
it('orders columns by animal_name and rows by date ascending', () => {
const statuses = [
{ animal_id: 'a1', date: '2026-07-02T00:00:00.000Z', analysis_summary: { total: 5 } },
{ animal_id: 'a2', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 9 } },
];
const m = buildMatrix(statuses, animals, totalParam);
expect(m.columns.map((c) => c.header)).toEqual(['Alpha', 'Beta']);
expect(m.rows.map((r) => r.date)).toEqual(['2026-07-01', '2026-07-02']);
// Row for 07-01: Alpha blank, Beta 9
expect(m.rows[0].values).toEqual(['', 9]);
// Row for 07-02: Alpha 5, Beta blank
expect(m.rows[1].values).toEqual([5, '']);
});
it('includes only dates that have at least one value (0 counts as a value)', () => {
const statuses = [
{ animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 0 } },
{ animal_id: 'a1', date: '2026-07-03T00:00:00.000Z', analysis_summary: null }, // no value
];
const m = buildMatrix(statuses, animals, totalParam);
expect(m.rows.map((r) => r.date)).toEqual(['2026-07-01']);
expect(m.rows[0].values).toEqual([0, '']);
});
it('disambiguates duplicate animal names with the id string', () => {
const dupAnimals = [
{ id: 'a1', animal_name: 'Rat', animal_id_string: 'R-001' },
{ id: 'a2', animal_name: 'Rat', animal_id_string: 'R-002' },
];
const statuses = [{ animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 1 } }];
const m = buildMatrix(statuses, dupAnimals, totalParam);
expect(m.columns.map((c) => c.header)).toEqual(['Rat (R-001)', 'Rat (R-002)']);
});
it('keeps the first status when a subject has duplicates on one date', () => {
const statuses = [
{ animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 7 } },
{ animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 99 } },
];
const m = buildMatrix(statuses, animals, totalParam);
expect(m.rows[0].values).toEqual([7, '']);
});
it('returns empty rows when no status has a value', () => {
const m = buildMatrix([{ animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: null }], animals, totalParam);
expect(m.rows).toEqual([]);
expect(m.columns.length).toBe(2);
});
});
import { toCSV, csvFilename } from '../src/lib/dataExport';
describe('toCSV', () => {
it('writes a header row of Date + column headers, then data rows', () => {
const matrix = {
columns: [{ id: 'a1', header: 'Alpha' }, { id: 'a2', header: 'Beta' }],
rows: [{ date: '2026-07-01', values: [5, ''] }, { date: '2026-07-02', values: ['', 9] }],
};
expect(toCSV(matrix)).toBe('Date,Alpha,Beta\n2026-07-01,5,\n2026-07-02,,9');
});
it('escapes commas, quotes, and newlines per RFC 4180', () => {
const matrix = {
columns: [{ id: 'a1', header: 'Note, field' }],
rows: [{ date: '2026-07-01', values: ['he said "hi"'] }, { date: '2026-07-02', values: ['line1\nline2'] }],
};
expect(toCSV(matrix)).toBe(
'Date,"Note, field"\n2026-07-01,"he said ""hi"""\n2026-07-02,"line1\nline2"',
);
});
});
describe('csvFilename', () => {
it('slugifies the experiment title and parameter label', () => {
expect(csvFilename('My Study #1', 'Success rate')).toBe('My-Study-1-Success-rate.csv');
});
it('falls back to "export" when both slugs are empty', () => {
expect(csvFilename('', '')).toBe('export.csv');
});
});
+13
View File
@@ -0,0 +1,13 @@
import '@testing-library/jest-dom';
// Suppress noisy act() warnings from async state updates in useEffect hooks.
// All assertions still run — this only silences the console.error spam that
// causes OOM crashes when running the full suite.
const originalError = console.error;
beforeAll(() => {
console.error = (...args) => {
if (typeof args[0] === 'string' && args[0].includes('not wrapped in act')) return;
originalError(...args);
};
});
afterAll(() => { console.error = originalError; });
+52 -1
View File
@@ -123,4 +123,55 @@ All PUT and DELETE routes pass through `auditMiddleware` which:
- All tests run inside Docker via `docker-compose.test.yml`
## Red Team Audit
_To be filled after Phase 3 testing is complete._
### Findings
#### 🔴 Critical
1. **CORS wildcard (`origin: '*'`)** — The backend accepts requests from any origin. An attacker on a different domain could make authenticated browser requests to the API if any auth is added later, or exfiltrate data via CSRF.
- **Fix**: Restrict `CORS_ORIGIN` to the frontend's origin in production.
2. **Prisma `migrate deploy` via `execSync` on server start** — Running migrations inline at startup means a crashing migration kills the entire service process. Also, injecting `DATABASE_URL` with special characters could theoretically escape the shell via `child_process.execSync`.
- **Fix**: Run migrations as a separate Docker entrypoint step, not inside the Node process.
3. **Unparameterized `table_name` in audit logs route**`table_name` is accepted as a plain string from query params and passed directly to Prisma. Prisma ORM prevents SQL injection here, but the field is never validated against a known allowlist — an attacker can write arbitrary strings to query params and pollute logs.
- **Fix**: Validate `tableName` against an allowlist: `['experiments', 'animals', 'daily_statuses', 'audit_logs']`.
#### 🟡 Medium
4. **No rate limiting** — All API endpoints are open to brute-force or denial-of-service via request flooding. Express has no rate limiter middleware.
- **Fix**: Add `express-rate-limit` on all `/api/*` routes.
5. **Helmet defaults only**`helmet()` is enabled but with defaults; notably `Content-Security-Policy` is not tuned for the SPA. The nginx proxy serves the frontend without explicit CSP headers either.
- **Fix**: Add a CSP header in nginx and strengthen helmet config.
6. **Docker: no `read_only` filesystem or user restriction** — The backend and frontend containers run as root by default.
- **Fix**: Add `user: node` and `read_only: true` (with tmpfs for writable paths) in `docker-compose.yml`.
7. **Postgres port exposed on host**`docker-compose.yml` exposes port 5432 externally. In production, only the backend container needs DB access.
- **Fix**: Remove host-side port mapping for postgres; use internal Docker networking only.
8. **`NODE_ENV=production` skip for Prisma generate** — `npx prisma generate` is not called in the production Dockerfile before `npm ci --only=production`, so the generated client may be missing.
- **Fix**: Run `prisma generate` in the builder stage.
#### 🟢 UX / Low
9. **Audit log section on Dashboard shows ALL experiments audit logs** (no `recordId` filter) — this is intentional but confusing; a large table will render hundreds of rows without pagination controls.
- **Fix**: Add pagination controls to `AuditLogSection`.
10. **No loading skeleton on list pages** — a "Loading…" text string isn't accessible; screen readers get no meaningful feedback.
- **Fix**: Add `aria-live="polite"` region for loading state.
11. **Form submission does not disable all interactive elements** — While the submit button shows a spinner, the Cancel button remains active and can unmount the form mid-submission, causing a React state update on an unmounted component.
- **Fix**: Disable Cancel button while `loading` is true (already done for inputs, extend to Cancel).
### Patches Applied (feature/red-team-fixes)
- Restricted CORS to env-configurable origin
- Moved migration out of `startServer()` into Docker entrypoint script
- Added `tableName` allowlist validation on audit logs route
- Added `express-rate-limit` (100 req/min per IP on all API routes)
- Removed exposed postgres port from docker-compose
- Added `user: node` to backend docker-compose service
- Fixed Prisma generate in production Dockerfile
- Disabled Cancel button during form submission
+75
View File
@@ -0,0 +1,75 @@
#!/bin/bash
# Generates dummy session files for drag-and-drop testing on the day view.
# Files are tiny (just named correctly) — the browser File API only reads metadata.
#
# Usage: bash generate_test_files.sh [DATE] [IDS...]
# DATE defaults to today (YYYY-MM-DD)
# IDS defaults to the three real subject IDs from the reach-task experiment
#
# Output: ./session_files/<DATE>/
DATE=${1:-$(date +%Y-%m-%d)}
shift
SUBJECTS=("${@}")
if [ ${#SUBJECTS[@]} -eq 0 ]; then
# Default IDs matching the real subjects in the experiments database
# (animal_id_string values visible on the day view)
SUBJECTS=("2852" "3076" "3077" "3078")
fi
OUTDIR="./session_files/${DATE}"
mkdir -p "$OUTDIR"
echo "Generating test files in ${OUTDIR}/ for subjects: ${SUBJECTS[*]}"
echo ""
for ID in "${SUBJECTS[@]}"; do
# File 1: large binary session recording (~placeholder, 0 bytes in test)
# Real file would be ~10 GB (e.g. raw ephys .bin)
BIN1="${OUTDIR}/${DATE}_${ID}_ephys_raw.bin"
touch "$BIN1"
echo " [~10 GB] ${DATE}_${ID}_ephys_raw.bin"
# File 2: large HDF5 spike-sorted output (~placeholder, 0 bytes in test)
# Real file would be ~10 GB (e.g. kilosort output .h5)
BIN2="${OUTDIR}/${DATE}_${ID}_kilosort.h5"
touch "$BIN2"
echo " [~10 GB] ${DATE}_${ID}_kilosort.h5"
# File 3: CSV trial events (~10 MB real, 1 KB here)
CSV1="${OUTDIR}/${DATE}_${ID}_trials.csv"
{
echo "trial_num,timestamp,outcome,reaction_time_ms,reward"
for i in $(seq 1 20); do
OUTCOME=$( [ $((RANDOM % 3)) -eq 0 ] && echo "Failure" || echo "Success" )
printf "%d,%.3f,%s,%d,%d\n" \
"$i" "$(echo "$i * 0.823 + 1.0" | bc -l 2>/dev/null || echo $i)" \
"$OUTCOME" "$((RANDOM % 400 + 50))" "$((RANDOM % 2))"
done
} > "$CSV1"
echo " [~10 MB] ${DATE}_${ID}_trials.csv"
# File 4: CSV behavioral log (~10 MB real, small here)
CSV2="${OUTDIR}/${DATE}_${ID}_behavior_log.csv"
{
echo "ts,event,value,notes"
echo "0.000,session_start,1,"
echo "1.234,trial_start,1,"
echo "1.856,reach_onset,1,"
echo "2.102,reach_end,1,"
echo "2.103,outcome,Success,"
echo "3.500,trial_start,2,"
echo "4.100,reach_onset,1,"
echo "4.950,outcome,Failure,"
echo "9999.9,session_end,1,"
} > "$CSV2"
echo " [~10 MB] ${DATE}_${ID}_behavior_log.csv"
echo ""
done
TOTAL=$(find "$OUTDIR" -type f | wc -l)
echo "Done — ${TOTAL} files in ${OUTDIR}/"
echo ""
echo "Drag the entire '${DATE}' folder into the day-view drop zone at:"
echo " https://experiments.sam-tran.com/experiments/<id>/day/${DATE}?field=<fieldId>"
@@ -0,0 +1,10 @@
ts,event,value,notes
0.000,session_start,1,
1.234,trial_start,1,
1.856,reach_onset,1,
2.102,reach_end,1,
2.103,outcome,Success,
3.500,trial_start,2,
4.100,reach_onset,1,
4.950,outcome,Failure,
9999.9,session_end,1,
1 ts event value notes
2 0.000 session_start 1
3 1.234 trial_start 1
4 1.856 reach_onset 1
5 2.102 reach_end 1
6 2.103 outcome Success
7 3.500 trial_start 2
8 4.100 reach_onset 1
9 4.950 outcome Failure
10 9999.9 session_end 1
@@ -0,0 +1,21 @@
trial_num,timestamp,outcome,reaction_time_ms,reward
1,1.823,Failure,176,0
2,2.646,Failure,378,0
3,3.469,Failure,138,1
4,4.292,Success,150,1
5,5.115,Success,204,1
6,5.938,Success,209,0
7,6.761,Success,369,0
8,7.584,Failure,84,0
9,8.407,Failure,403,0
10,9.230,Success,340,0
11,10.053,Success,442,1
12,10.876,Success,355,0
13,11.699,Success,280,0
14,12.522,Failure,430,1
15,13.345,Success,60,1
16,14.168,Failure,102,0
17,14.991,Success,213,0
18,15.814,Success,54,1
19,16.637,Failure,88,0
20,17.460,Failure,290,1
1 trial_num timestamp outcome reaction_time_ms reward
2 1 1.823 Failure 176 0
3 2 2.646 Failure 378 0
4 3 3.469 Failure 138 1
5 4 4.292 Success 150 1
6 5 5.115 Success 204 1
7 6 5.938 Success 209 0
8 7 6.761 Success 369 0
9 8 7.584 Failure 84 0
10 9 8.407 Failure 403 0
11 10 9.230 Success 340 0
12 11 10.053 Success 442 1
13 12 10.876 Success 355 0
14 13 11.699 Success 280 0
15 14 12.522 Failure 430 1
16 15 13.345 Success 60 1
17 16 14.168 Failure 102 0
18 17 14.991 Success 213 0
19 18 15.814 Success 54 1
20 19 16.637 Failure 88 0
21 20 17.460 Failure 290 1
@@ -0,0 +1,10 @@
ts,event,value,notes
0.000,session_start,1,
1.234,trial_start,1,
1.856,reach_onset,1,
2.102,reach_end,1,
2.103,outcome,Success,
3.500,trial_start,2,
4.100,reach_onset,1,
4.950,outcome,Failure,
9999.9,session_end,1,
1 ts event value notes
2 0.000 session_start 1
3 1.234 trial_start 1
4 1.856 reach_onset 1
5 2.102 reach_end 1
6 2.103 outcome Success
7 3.500 trial_start 2
8 4.100 reach_onset 1
9 4.950 outcome Failure
10 9999.9 session_end 1
@@ -0,0 +1,21 @@
trial_num,timestamp,outcome,reaction_time_ms,reward
1,1.823,Failure,56,0
2,2.646,Failure,299,1
3,3.469,Failure,335,0
4,4.292,Failure,70,1
5,5.115,Success,273,0
6,5.938,Success,59,1
7,6.761,Success,159,0
8,7.584,Success,268,1
9,8.407,Success,330,0
10,9.230,Success,365,1
11,10.053,Success,371,0
12,10.876,Success,230,1
13,11.699,Success,74,0
14,12.522,Failure,381,1
15,13.345,Success,70,1
16,14.168,Failure,215,0
17,14.991,Failure,310,0
18,15.814,Success,78,1
19,16.637,Success,79,1
20,17.460,Failure,303,1
1 trial_num timestamp outcome reaction_time_ms reward
2 1 1.823 Failure 56 0
3 2 2.646 Failure 299 1
4 3 3.469 Failure 335 0
5 4 4.292 Failure 70 1
6 5 5.115 Success 273 0
7 6 5.938 Success 59 1
8 7 6.761 Success 159 0
9 8 7.584 Success 268 1
10 9 8.407 Success 330 0
11 10 9.230 Success 365 1
12 11 10.053 Success 371 0
13 12 10.876 Success 230 1
14 13 11.699 Success 74 0
15 14 12.522 Failure 381 1
16 15 13.345 Success 70 1
17 16 14.168 Failure 215 0
18 17 14.991 Failure 310 0
19 18 15.814 Success 78 1
20 19 16.637 Success 79 1
21 20 17.460 Failure 303 1

Some files were not shown because too many files have changed in this diff Show More