91 Commits

Author SHA1 Message Date
Experiments DB Dev 13414d31fc matlab(tdcs): assert reference group present; README scope note
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 08:08:27 -04:00
Experiments DB Dev 01f59a5265 matlab(tdcs): report, switch/case entry, runners, README 2026-07-20 08:00:40 -04:00
Experiments DB Dev 27d388530a matlab(tdcs): GLMM + per-animal + classification models 2026-07-20 07:51:30 -04:00
Experiments DB Dev 75ea8a031e test(matlab): fix uint64 vs double class mismatch in group-count assertion
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 07:43:05 -04:00
Experiments DB Dev e398237c76 matlab(tdcs): scenario derivation + derived data export 2026-07-20 07:39:12 -04:00
Experiments DB Dev 33121e3a8f matlab(tdcs): data loading + curated group map 2026-07-20 07:29:31 -04:00
Experiments DB Dev 808040a522 docs(plan): MATLAB tDCS analysis port implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 07:23:57 -04:00
Experiments DB Dev 2e6464cd14 docs(spec): MATLAB port of the tDCS GLM analysis
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 07:14:59 -04:00
Experiments DB Dev 5314071c6b analysis(tdcs): GLM hypothesis tests + methods for the reaching study
Poisson GEE / binomial rate / learning-rate models, subject random-intercept
sensitivity, unknown-group classification and merge scenarios, plus METHODS.md
and learning-curve plots. Data reconstructed from the experiment DB and
verified against the exported matrix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:07:17 -04:00
Experiments DB Dev 86feec2e77 fix(export): label synthetic ordinal '# Days Reach (ordinal)' to avoid collision with same-named daily field
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:35:22 -04:00
Experiments DB Dev 4f3fe64cad test(export): stale-group + same-date ordinal coverage; comment/guard polish 2026-07-19 15:18:56 -04:00
Experiments DB Dev 33ee191966 refactor(export): remove dead assignable-axes helpers and their tests
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 15:12:15 -04:00
Experiments DB Dev 6f23f83bc8 feat(export): modal becomes X / Data / Group by pickers, subjects as columns 2026-07-19 15:05:38 -04:00
Experiments DB Dev 39a5a4ec0c feat(export): buildSubjectSeriesMatrix — subjects as columns over an X field 2026-07-19 14:59:42 -04:00
Experiments DB Dev 8510903771 feat(export): X-coordinate helpers (date, # days reach, daily field) 2026-07-19 14:54:25 -04:00
Experiments DB Dev 3e52117c1a docs(plan): subject-series export implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 14:36:10 -04:00
Experiments DB Dev 8153777951 docs(spec): subject-series export — choose X field + data value, subjects as columns
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 14:29:20 -04:00
Experiments DB Dev 9b3d52d89b fix(export): default group-by to None when unset/stale; polish
Final code review of the configurable-data-export feature: ExperimentDetail
was forwarding its on-page groupField state (defaults to '__name__') into
ExportDataModal, leaking a clustering default into the default (untouched)
export. Now passes the raw saved localStorage value, defaulting to
'__none__'. The modal also now validates the incoming groupField against
'__none__'/'__name__'/'__id__'/active subjectTemplate fields and coerces to
'__none__' otherwise, guarding against stale/deleted subject_info fields.
Plus a stale module comment fix and optional-chaining consistency fix in
listSubjectMembers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 13:59:48 -04:00
Experiments DB Dev e65de8892a feat(export): pass subjectTemplate + saved group field to export modal 2026-07-19 13:41:15 -04:00
Experiments DB Dev 8ab270a9c9 feat(export): modal gains assignable axes + group-by control 2026-07-19 13:33:12 -04:00
Experiments DB Dev 958666580f feat(export): toCSV emits context line + subject group cell 2026-07-19 13:28:32 -04:00
Experiments DB Dev 600323f40a feat(export): generalized buildMatrix over assignable dimensions 2026-07-19 13:22:53 -04:00
Experiments DB Dev 7d112de0d7 feat(export): subject/parameter members + dimension dispatcher 2026-07-19 13:19:15 -04:00
Experiments DB Dev a411d59305 feat(export): dimension primitives, date members, status index 2026-07-19 13:16:33 -04:00
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
113 changed files with 18353 additions and 363 deletions
+158
View File
@@ -0,0 +1,158 @@
# tDCS reaching study — GLM methods
This document explains the statistics in `tdcs_glm.py`: the models, the exact
formulas, how each subject's progress is accounted for, and the caveats.
## The question
Two **known** conditions anchor the performance scale and differ from each other:
- **H2 (anchor check):** `Electrode-Box-B2` performs **better** than `Electrode-Box-A2`.
Two **unknown** conditions are then **classified** against those anchors — for
each of `Electrode-Box-A` and `Right-Electrode`, is it **A2-like** or **B2-like**?
We do *not* assume either belongs to B2; each unknown is compared to *both*
anchors, and the anchor it cannot be distinguished from is its likely class.
There is also a **merged-assumption** mode (`--merge`): assume the two unknowns
resolve as `Right-Electrode == Box-B2` and `Electrode-Box-A == Box-A2`, fold them
into the anchors, and re-estimate everything with 4 subjects per group.
## Data and outcome
- **Outcome:** `success` = successful reaches in a session
(`analysis_summary.counts.Success`), a non-negative **count**.
- **Attempts:** `total` = reach attempts in the session (`analysis_summary.total`);
used as the denominator for the rate model. `success_rate == success / total`.
- **Time:** `day` = the "# Days Reach" field (training day, 0..26). Analyses use
days ≥ 0 (pre-training negative days and zero-attempt sessions are excluded;
zero-attempt sessions are undefined for the rate model).
- **Groups (subjects):** Naive (4), Box-A2 (3), Box-B2 (3), Box-A (1),
Right-Electrode (1). Under `--merge`: Box-A2 (4), Box-B2 (4), Naive (4).
**Provenance:** `tdcs_reach_data.csv` was reconstructed from the experiment
database and verified cell-by-cell against the exported matrix (71/71
unambiguous cells on days 05 matched exactly).
## The three models
All models use `Electrode-Box-B2` as the **reference** group, so each group term
is that group's contrast *versus Box-B2*. `day_c` is the centered training day and
`day_c2 = day_c²` captures the rise-then-plateau of the learning curve.
### (A) Level — count (primary)
Poisson GEE on the success counts, clustered by subject:
```
success ~ C(group, Treatment('Electrode-Box-B2')) + day_c + day_c2
family = Poisson (log link)
groups = subject # repeated-measures cluster
cov_struct = Exchangeable # working within-subject correlation
SE = robust (sandwich)
```
`exp(coef)` for a group term is an **incidence-rate ratio (IRR)**: expected
successes relative to Box-B2.
### (B) Level — rate
Binomial GLM on successes-out-of-attempts, with cluster-robust SEs by subject:
```
cbind(success, total - success) ~ C(group, Treatment('Electrode-Box-B2')) + day_c + day_c2
family = Binomial (logit link)
cov_type = cluster (groups = subject)
```
`exp(coef)` is an **odds ratio** for a successful reach relative to Box-B2. This
controls for differing numbers of attempts, so it answers "who is more accurate
per attempt?" rather than "who attempts more?"
### (C) Learning rate
Poisson GEE with a **group × day** interaction, to ask whether groups improve at
different *rates* (not just different levels):
```
success ~ C(group, Treatment('Electrode-Box-B2')) * day_c + day_c2
```
Each `group[T.X]:day_c` term is the difference in log-slope versus Box-B2; a joint
Wald test asks whether *any* group's slope differs. Large p ⇒ parallel learning.
### Anchors-only model
The A2-vs-B2 comparison, refit on **just the two anchor groups** so nothing else
influences the shared day terms or the dispersion/correlation nuisance:
```
# subset to {Box-A2, Box-B2}
success ~ C(group, Treatment('Electrode-Box-B2')) + day_c + day_c2 # count
cbind(success, total-success) ~ C(group, ...) + day_c + day_c2 # rate
```
With B2 as reference the single group term is the A2-vs-B2 effect; H2 is a
one-sided test that this coefficient is below zero.
## How each subject's progress is accounted for
Two distinct pieces:
1. **Progress over training** — the `day_c + day_c2` fixed terms model the average
learning curve, so groups are compared at comparable points in training rather
than being confounded by *when* each was measured.
2. **Repeated measures / individual baselines** — each subject contributes many
correlated sessions and has its own baseline. Handled two ways:
- **Primary (GEE):** subject is the cluster; an exchangeable working
correlation plus robust (sandwich) SEs give *population-average* group
effects whose inference is valid under within-subject correlation and
Poisson overdispersion.
- **Sensitivity (mixed model):** a Poisson model with a **per-subject random
intercept** (`(1 | subject)`) so each animal gets its own baseline level;
the group effects are estimated after allowing for that individual variation.
statsmodels has no frequentist Poisson GLMM, so this is the **MAP/Laplace**
fit (`fit_vb` diverges on these large counts). Its p-values are approximate —
read it as a direction/magnitude check that should agree with GEE.
## Classification logic (unknowns)
For each unknown group, `diff_contrast` builds a linear contrast of that group
against **each anchor** (both coded vs the B2 reference) and tests it:
- indistinguishable from B2 **and** different from A2 → **B2-like**
- indistinguishable from A2 **and** different from B2 → **A2-like**
- indistinguishable from both → **ambiguous** (report the numerically nearer one)
- different from both → **unlike both**
## Caveats
- **Tiny groups.** Box-A2/B2 have 3 subjects, Naive 4, and each unknown has
**n = 1 subject**. Classifying a one-subject condition is weak: "matches
anchor X" means "not statistically distinguishable from X," **not** proof of
equivalence.
- **Single-cluster fragility.** Cluster-robust/GEE inference with one cluster in a
group can produce artificially small SEs — most visibly the learning-rate
interaction for the n=1 groups; do not read those p-values literally.
- **Mixed-model confounding.** With a per-subject random intercept, a group made
of one subject is partly confounded with that subject's random intercept, so its
fixed effect is shrunk.
- **Rate vs count.** "Success" alone is a count; the rate model (success/attempts)
is the fairer accuracy comparison when attempt counts differ.
## Files and usage
- `tdcs_glm.py` — the analysis (run it directly).
- `tdcs_reach_data.csv` — the verified long-format data (`subject, group, day, success, total`).
- `tdcs_learning_curves*.png` — per-group learning curves (count and rate panels).
```
python3 analysis/tdcs_glm.py # all training days, 5 groups
python3 analysis/tdcs_glm.py --max-day 10 # restrict to days 010
python3 analysis/tdcs_glm.py --merge # assume Right==B2 and Box-A==A2 (3 groups)
```
Requires: pandas, numpy, scipy, statsmodels, matplotlib.
Binary file not shown.
+109
View File
@@ -0,0 +1,109 @@
# tDCS reaching study — MATLAB GLM port
This directory is a MATLAB port of the Python tDCS GLM analysis
(`analysis/tdcs_glm.py`, documented in `analysis/METHODS.md`), driven by raw
app-export matrices rather than the pre-joined `tdcs_reach_data.csv`. It
reproduces the same three models (count level, rate level, learning rate) and
the same H2 anchor check / unknown-condition classification, but replaces the
Python's population-average GEE with a `fitglme` GLMM (Poisson/Binomial,
subject random intercept) — see "GLMM vs GEE" below.
## Requirements
- MATLAB R2025b + Statistics and Machine Learning Toolbox.
- A license activated at `~/matlab/licenses/license.dat` (already in place in
this environment). If MATLAB ever reports it is unlicensed, activate with:
```
~/matlab/bin/activate_matlab.sh
```
## Layout
- `tdcs_config.m` — curated 12-animal group map, reference group, merge maps.
- `tdcs_load_data.m` — parses `raw/raw_success.csv` and `raw/raw_total.csv`
(app-export matrix format), joins them, and applies the curated group map.
- `tdcs_scenario_data.m` — builds one of the 6 scenarios (merge map + day
window), and saves the scenario table to `derived/<scenario>.csv`.
- `tdcs_models.m` — fits the count/rate/learning GLMEs, the per-animal
Welch/Mann-Whitney tests, the H2 anchor check, and (unmerged scenarios
only) the unknown-condition classification.
- `tdcs_report.m` — prints descriptives, the raw GLME fixed-effects tables,
and an interpretation section (H2 verdict, per-animal p-values,
classification, caveats); also writes `results/<scenario>.txt`.
- `tdcs_glm.m` — the entry point: `tdcs_glm(scenario)` runs
`tdcs_scenario_data` → `tdcs_models` → `tdcs_report` for one named
scenario, via an explicit `switch/case` over all 6 scenario names.
- `run_all.m` — runs all 6 scenarios in one script.
- `run_tests.m` — runs the `matlab.unittest` suite in `tests/` and exits
nonzero on any failure.
- `tests/` — `matlab.unittest` test classes (`tLoadData`, `tScenarioData`,
`tModels`) with golden values cross-checked against the Python.
- `raw/` — pre-generated app-export CSVs (input; not modified by this code).
- `derived/` — scenario tables written by `tdcs_scenario_data` (generated).
- `results/` — report text files written by `tdcs_report`/`tdcs_glm`
(generated).
## Running
Run one scenario (valid names: `unmerged_full`, `unmerged_d0_10`,
`mergeA2_full`, `mergeA2_d0_10`, `mergeB2_full`, `mergeB2_d0_10`):
```
cd analysis/matlab
~/matlab/bin/matlab -batch "addpath(pwd); tdcs_glm('mergeB2_full')"
```
This prints the full report to the console and writes it to
`results/mergeB2_full.txt`.
Run all 6 scenarios:
```
~/matlab/bin/matlab -batch "addpath(pwd); run_all"
```
Run the test suite:
```
~/matlab/bin/matlab -batch "addpath(pwd); run_tests"
```
(`run_tests` calls `error(...)` on any failure, so the `matlab -batch`
process exits with a nonzero status — suitable for CI.)
## Validation against the Python
Per-animal statistics (per-subject Welch t-test / one-sided Mann-Whitney U,
Box-B2 vs Box-A2) are pure stats independent of the GLM choice, and were
matched to the Python's golden values to 3 decimals across all 6 scenarios
(see `docs/superpowers/plans/2026-07-20-matlab-tdcs-analysis.md`, Task 3).
## GLMM vs GEE (read this before comparing numbers to the Python)
The Python reference uses **GEE** (population-average effects, exchangeable
working correlation, robust/sandwich SEs) for the count and learning-rate
models, and a cluster-robust Binomial GLM for the rate model. This MATLAB
port instead uses **GLMM** (`fitglme`, subject-specific effects via a
`(1|subject)` random intercept, Laplace/PL approximation) for all three
models, because MATLAB's Statistics and Machine Learning Toolbox has no GEE
implementation.
GLMM and GEE estimate related but distinct quantities (subject-specific vs.
population-average effects), so **exact coefficients, ratios, and p-values
will differ** between the two implementations — sometimes by a fair amount
for the small, unbalanced groups in this study. What should agree is the
**interpretation**: the direction of the H2 anchor effect, which unknown
condition classifies as A2-like/B2-like/ambiguous, and whether the learning
rates differ across groups. Treat the MATLAB numbers as a sensitivity check
on the Python's conclusions, not as a numerically-identical reproduction.
## Scope vs. the Python
The MATLAB port reproduces the Python's three core models (count, rate,
learning-rate), per-animal tests, unknown-group classification, and the H2
verdict (read off the main model's A2-vs-B2 term). The Python's two auxiliary
sensitivity sections — `report_anchor_only` (a two-anchor-only refit) and
`report_mixed` (a Bayesian MAP random-intercept model) — are intentionally
**not** ported: the anchor-only refit is closely tracked by the main-model H2
term, and MATLAB's native `fitglme` already *is* the random-intercept model.
+35
View File
@@ -0,0 +1,35 @@
Data,Success
Group,,,Electrode-Box-A,Electrode-Box-A2,Electrode-Box-A2,Electrode-Box-A2,Electrode-Box-B2,Electrode-Box-B2,Electrode-Box-B2,Electrode-Box-B2,Left-Electrode-Cathodal,Naive,Naive,Naive,Right-Electrode
# Days Reach,Flan-1,Vu-vuong,Khoai-tay-1,Banh-mi-2,Egg-tart-2,Root-beer-2,Banh-mi-1,Egg-tart-1,Flan-2,Root-beer-1,OM-1,Khoai-lang-2,Khoai-tay-2,OM-2,Khoai-lang-1
-4,,,,,,,,,,,,,,,0
-3,,,,,,,,,,,,,,,2
-2,,,1,,,,,,,,,,,0,
-1,,,3,2,,,,,,,,,,5,0
0,,18,14,25,9,22,13,7,,11,0,0,,1,3
1,,35,22,22,2,31,35,16,,18,1,0,21,11,18
2,,61,6,22,31,49,47,23,,40,3,10,6,19,27
3,,34,28,29,44,31,65,63,,55,1,11,0,29,46
4,,61,60,27,54,60,70,69,,75,11,9,28,73,65
5,,37,75,43,84,84,102,83,,64,5,34,31,53,76
6,,49,81,74,85,,90,71,,104,9,21,62,73,79
7,,65,78,70,79,,109,79,,98,,23,87,80,81
8,,67,91,65,76,,104,98,,81,,64,105,91,98
9,,73,99,79,88,,119,89,,89,,75,75,90,101
10,,72,94,93,81,,121,96,,105,,63,101,95,103
11,,89,110,,78,,121,96,,,,59,102,60,113
12,,97,105,,96,,120,101,,,,51,95,58,117
13,,93,106,,84,,135,103,,,,73,77,,114
14,,75,91,,,,,97,,,,82,91,,119
15,,68,,,,,,,,,,70,,,111
16,,96,,,,,,,,,,76,,,74
17,,68,,,,,,,,,,76,,,109
18,,81,,,,,,,,,,63,,,88
19,,66,,,,,,,,,,48,,,98
20,,84,,,,,,,,,,65,,,118
21,,66,,,,,,,,,,75,,,109
22,,,,,,,,,,,,98,,,106
23,,,,,,,,,,,,88,,,
24,,,,,,,,,,,,94,,,
25,,,,,,,,,,,,56,,,
26,,,,,,,,,,,,75,,,
1 Data,Success
2 Group,—,—,Electrode-Box-A,Electrode-Box-A2,Electrode-Box-A2,Electrode-Box-A2,Electrode-Box-B2,Electrode-Box-B2,Electrode-Box-B2,Electrode-Box-B2,Left-Electrode-Cathodal,Naive,Naive,Naive,Right-Electrode
3 # Days Reach,Flan-1,Vu-vuong,Khoai-tay-1,Banh-mi-2,Egg-tart-2,Root-beer-2,Banh-mi-1,Egg-tart-1,Flan-2,Root-beer-1,OM-1,Khoai-lang-2,Khoai-tay-2,OM-2,Khoai-lang-1
4 -4,,,,,,,,,,,,,,,0
5 -3,,,,,,,,,,,,,,,2
6 -2,,,1,,,,,,,,,,,0,
7 -1,,,3,2,,,,,,,,,,5,0
8 0,,18,14,25,9,22,13,7,,11,0,0,,1,3
9 1,,35,22,22,2,31,35,16,,18,1,0,21,11,18
10 2,,61,6,22,31,49,47,23,,40,3,10,6,19,27
11 3,,34,28,29,44,31,65,63,,55,1,11,0,29,46
12 4,,61,60,27,54,60,70,69,,75,11,9,28,73,65
13 5,,37,75,43,84,84,102,83,,64,5,34,31,53,76
14 6,,49,81,74,85,,90,71,,104,9,21,62,73,79
15 7,,65,78,70,79,,109,79,,98,,23,87,80,81
16 8,,67,91,65,76,,104,98,,81,,64,105,91,98
17 9,,73,99,79,88,,119,89,,89,,75,75,90,101
18 10,,72,94,93,81,,121,96,,105,,63,101,95,103
19 11,,89,110,,78,,121,96,,,,59,102,60,113
20 12,,97,105,,96,,120,101,,,,51,95,58,117
21 13,,93,106,,84,,135,103,,,,73,77,,114
22 14,,75,91,,,,,97,,,,82,91,,119
23 15,,68,,,,,,,,,,70,,,111
24 16,,96,,,,,,,,,,76,,,74
25 17,,68,,,,,,,,,,76,,,109
26 18,,81,,,,,,,,,,63,,,88
27 19,,66,,,,,,,,,,48,,,98
28 20,,84,,,,,,,,,,65,,,118
29 21,,66,,,,,,,,,,75,,,109
30 22,,,,,,,,,,,,98,,,106
31 23,,,,,,,,,,,,88,,,
32 24,,,,,,,,,,,,94,,,
33 25,,,,,,,,,,,,56,,,
34 26,,,,,,,,,,,,75,,,
+35
View File
@@ -0,0 +1,35 @@
Data,Total attempts
Group,,,Electrode-Box-A,Electrode-Box-A2,Electrode-Box-A2,Electrode-Box-A2,Electrode-Box-B2,Electrode-Box-B2,Electrode-Box-B2,Electrode-Box-B2,Left-Electrode-Cathodal,Naive,Naive,Naive,Right-Electrode
# Days Reach,Flan-1,Vu-vuong,Khoai-tay-1,Banh-mi-2,Egg-tart-2,Root-beer-2,Banh-mi-1,Egg-tart-1,Flan-2,Root-beer-1,OM-1,Khoai-lang-2,Khoai-tay-2,OM-2,Khoai-lang-1
-4,,,,,,,,,,,,,,,0
-3,,,,,,,,,20,,,,,,7
-2,,,18,,,,,,,,,,,0,
-1,,,24,42,,,,,,,,,,18,0
0,,61,62,97,32,74,84,56,,85,0,0,,7,69
1,,91,83,101,38,87,86,78,,76,10,0,68,33,97
2,,127,79,119,93,134,110,103,,105,5,47,79,62,84
3,,146,97,118,101,89,140,120,,134,2,52,83,117,121
4,,141,134,136,131,140,127,132,,136,44,56,87,126,143
5,,151,138,146,139,147,142,136,,133,23,95,125,135,141
6,,131,137,146,145,,131,142,,139,32,72,144,138,146
7,,149,147,148,143,,148,138,,148,,99,141,131,153
8,,141,132,130,131,,137,142,,145,,136,152,141,144
9,,140,146,151,149,,150,139,,156,,131,148,135,149
10,,131,143,152,151,,158,143,,158,,134,149,142,150
11,,158,156,,152,,148,148,,,,139,154,133,154
12,,154,143,,155,,149,156,,,,129,144,142,148
13,,145,153,,155,,154,152,,,,143,149,,146
14,,147,152,,,,,152,,,,136,153,,132
15,,142,,,,,,,,,,145,,,156
16,,162,,,,,,,,,,135,,,156
17,,144,,,,,,,,,,150,,,139
18,,134,,,,,,,,,,122,,,138
19,,144,,,,,,,,,,116,,,140
20,,127,,,,,,,,,,134,,,153
21,,133,,,,,,,,,,131,,,146
22,,,,,,,,,,,,146,,,141
23,,,,,,,,,,,,139,,,
24,,,,,,,,,,,,148,,,
25,,,,,,,,,,,,102,,,
26,,,,,,,,,,,,143,,,
1 Data,Total attempts
2 Group,—,—,Electrode-Box-A,Electrode-Box-A2,Electrode-Box-A2,Electrode-Box-A2,Electrode-Box-B2,Electrode-Box-B2,Electrode-Box-B2,Electrode-Box-B2,Left-Electrode-Cathodal,Naive,Naive,Naive,Right-Electrode
3 # Days Reach,Flan-1,Vu-vuong,Khoai-tay-1,Banh-mi-2,Egg-tart-2,Root-beer-2,Banh-mi-1,Egg-tart-1,Flan-2,Root-beer-1,OM-1,Khoai-lang-2,Khoai-tay-2,OM-2,Khoai-lang-1
4 -4,,,,,,,,,,,,,,,0
5 -3,,,,,,,,,20,,,,,,7
6 -2,,,18,,,,,,,,,,,0,
7 -1,,,24,42,,,,,,,,,,18,0
8 0,,61,62,97,32,74,84,56,,85,0,0,,7,69
9 1,,91,83,101,38,87,86,78,,76,10,0,68,33,97
10 2,,127,79,119,93,134,110,103,,105,5,47,79,62,84
11 3,,146,97,118,101,89,140,120,,134,2,52,83,117,121
12 4,,141,134,136,131,140,127,132,,136,44,56,87,126,143
13 5,,151,138,146,139,147,142,136,,133,23,95,125,135,141
14 6,,131,137,146,145,,131,142,,139,32,72,144,138,146
15 7,,149,147,148,143,,148,138,,148,,99,141,131,153
16 8,,141,132,130,131,,137,142,,145,,136,152,141,144
17 9,,140,146,151,149,,150,139,,156,,131,148,135,149
18 10,,131,143,152,151,,158,143,,158,,134,149,142,150
19 11,,158,156,,152,,148,148,,,,139,154,133,154
20 12,,154,143,,155,,149,156,,,,129,144,142,148
21 13,,145,153,,155,,154,152,,,,143,149,,146
22 14,,147,152,,,,,152,,,,136,153,,132
23 15,,142,,,,,,,,,,145,,,156
24 16,,162,,,,,,,,,,135,,,156
25 17,,144,,,,,,,,,,150,,,139
26 18,,134,,,,,,,,,,122,,,138
27 19,,144,,,,,,,,,,116,,,140
28 20,,127,,,,,,,,,,134,,,153
29 21,,133,,,,,,,,,,131,,,146
30 22,,,,,,,,,,,,146,,,141
31 23,,,,,,,,,,,,139,,,
32 24,,,,,,,,,,,,148,,,
33 25,,,,,,,,,,,,102,,,
34 26,,,,,,,,,,,,143,,,
+13
View File
@@ -0,0 +1,13 @@
%RUN_ALL Run all 6 tDCS GLM scenarios and write results/<scenario>.txt for each.
% Usage: matlab -batch "run_all"
scenarios = {'unmerged_full', 'unmerged_d0_10', ...
'mergeA2_full', 'mergeA2_d0_10', ...
'mergeB2_full', 'mergeB2_d0_10'};
for i = 1:numel(scenarios)
fprintf('\n\n### Running scenario: %s ###\n', scenarios{i});
tdcs_glm(scenarios{i});
end
fprintf('\n\nAll %d scenarios complete. See analysis/matlab/results/*.txt.\n', numel(scenarios));
+14
View File
@@ -0,0 +1,14 @@
%RUN_TESTS Run the full matlab.unittest suite and exit nonzero on failure.
% Usage: matlab -batch "run_tests"
% (add the analysis/matlab directory to the path first if it is not
% already on it, e.g. matlab -batch "addpath('analysis/matlab'); run_tests")
thisDir = fileparts(mfilename('fullpath'));
results = runtests(fullfile(thisDir, 'tests'));
disp(results);
fprintf('PASS=%d FAIL=%d\n', sum([results.Passed]), sum([results.Failed]));
if any([results.Failed])
error('run_tests:failed', 'One or more tests failed.');
end
+57
View File
@@ -0,0 +1,57 @@
function cfg = tdcs_config()
%TDCS_CONFIG Curated group map and merge maps for the tDCS reaching study.
% CFG = TDCS_CONFIG() returns a struct with:
% .groups containers.Map, subject name (char) -> curated group name
% (char), for the 12 curated animals only. All other
% animals present in the raw export are not in this map
% and must be dropped by callers.
% .ref reference group used to code the GLME group factor,
% 'Electrode-Box-B2'.
% .mergeA2 containers.Map collapsing the two UNKNOWN conditions
% toward the weaker anchor: Electrode-Box-A ->
% Electrode-Box-A2, Right-Electrode -> Electrode-Box-B2.
% .mergeB2 containers.Map collapsing both UNKNOWN conditions onto
% the stronger anchor: Electrode-Box-A -> Electrode-Box-B2,
% Right-Electrode -> Electrode-Box-B2.
% .anchorLow 'Electrode-Box-A2', the weaker of the two known anchors.
% .anchorHigh 'Electrode-Box-B2', the stronger known anchor (== ref).
% .unknowns cell array of the two ungrouped conditions that get
% classified against the two anchors: {'Electrode-Box-A',
% 'Right-Electrode'}.
%
% See docs/superpowers/plans/2026-07-20-matlab-tdcs-analysis.md (Global
% Constraints) for the source of these values.
% Curated 12-animal group map (by animal name).
subjectGroup = {
'Vu-vuong', 'Naive'
'Khoai-lang-2', 'Naive'
'Khoai-tay-2', 'Naive'
'OM-2', 'Naive'
'Khoai-tay-1', 'Electrode-Box-A'
'Banh-mi-2', 'Electrode-Box-A2'
'Egg-tart-2', 'Electrode-Box-A2'
'Root-beer-2', 'Electrode-Box-A2'
'Banh-mi-1', 'Electrode-Box-B2'
'Egg-tart-1', 'Electrode-Box-B2'
'Root-beer-1', 'Electrode-Box-B2'
'Khoai-lang-1', 'Right-Electrode'
};
cfg = struct();
cfg.groups = containers.Map(subjectGroup(:, 1), subjectGroup(:, 2));
cfg.ref = 'Electrode-Box-B2';
cfg.anchorLow = 'Electrode-Box-A2';
cfg.anchorHigh = 'Electrode-Box-B2';
cfg.unknowns = {'Electrode-Box-A', 'Right-Electrode'};
cfg.mergeA2 = containers.Map( ...
{'Electrode-Box-A', 'Right-Electrode'}, ...
{'Electrode-Box-A2', 'Electrode-Box-B2'});
cfg.mergeB2 = containers.Map( ...
{'Electrode-Box-A', 'Right-Electrode'}, ...
{'Electrode-Box-B2', 'Electrode-Box-B2'});
end
+64
View File
@@ -0,0 +1,64 @@
function tdcs_glm(scenario)
%TDCS_GLM Run one tDCS GLM scenario end-to-end and report it.
% TDCS_GLM(SCENARIO) runs the full pipeline for one named scenario --
% TDCS_SCENARIO_DATA -> TDCS_MODELS -> TDCS_REPORT -- printing the
% report to the console and writing it to
% analysis/matlab/results/<SCENARIO>.txt.
%
% SCENARIO must be one of (case-sensitive):
% unmerged_full, unmerged_d0_10,
% mergeA2_full, mergeA2_d0_10,
% mergeB2_full, mergeB2_d0_10
%
% Each case below is a self-contained, independently invocable call of
% the same three-function pipeline; they are enumerated explicitly
% (rather than sharing one generic branch) so each scenario name is
% greppable on its own, per the project's spec.
%
% Example:
% tdcs_glm('mergeB2_full')
%
% See analysis/tdcs_glm.py (the Python this ports) and
% docs/superpowers/plans/2026-07-20-matlab-tdcs-analysis.md (Task 4).
cfg = tdcs_config();
switch scenario
case 'unmerged_full'
[S, meta] = tdcs_scenario_data('unmerged_full');
R = tdcs_models(S, meta, cfg);
tdcs_report(S, R, meta, cfg, 'unmerged_full');
case 'unmerged_d0_10'
[S, meta] = tdcs_scenario_data('unmerged_d0_10');
R = tdcs_models(S, meta, cfg);
tdcs_report(S, R, meta, cfg, 'unmerged_d0_10');
case 'mergeA2_full'
[S, meta] = tdcs_scenario_data('mergeA2_full');
R = tdcs_models(S, meta, cfg);
tdcs_report(S, R, meta, cfg, 'mergeA2_full');
case 'mergeA2_d0_10'
[S, meta] = tdcs_scenario_data('mergeA2_d0_10');
R = tdcs_models(S, meta, cfg);
tdcs_report(S, R, meta, cfg, 'mergeA2_d0_10');
case 'mergeB2_full'
[S, meta] = tdcs_scenario_data('mergeB2_full');
R = tdcs_models(S, meta, cfg);
tdcs_report(S, R, meta, cfg, 'mergeB2_full');
case 'mergeB2_d0_10'
[S, meta] = tdcs_scenario_data('mergeB2_d0_10');
R = tdcs_models(S, meta, cfg);
tdcs_report(S, R, meta, cfg, 'mergeB2_d0_10');
otherwise
error('tdcs_glm:badScenario', ...
['Unrecognized scenario "%s". Valid scenarios are: ' ...
'unmerged_full, unmerged_d0_10, mergeA2_full, mergeA2_d0_10, ' ...
'mergeB2_full, mergeB2_d0_10.'], scenario);
end
end
+117
View File
@@ -0,0 +1,117 @@
function T = tdcs_load_data()
%TDCS_LOAD_DATA Load, join, and curate the raw tDCS reaching matrices.
% T = TDCS_LOAD_DATA() reads analysis/matlab/raw/raw_success.csv and
% raw_total.csv (app-exported matrix format: metric header line, blank
% line, file "Group" line, "# Days Reach" subject-header line, then one
% row per day with one value per subject column), reshapes each to long
% form, joins them on (subject, day), applies the curated 12-animal
% group map from tdcs_config (the file's own Group row is ignored),
% and restricts to day >= 0.
%
% Returns a table with variables:
% subject (string) animal name
% group (categorical) curated group (Naive, Electrode-Box-A,
% Electrode-Box-A2, Electrode-Box-B2,
% Right-Electrode)
% day (double) "# Days Reach" value, >= 0
% success (double) successful reaches in the session
% total (double) total attempts in the session
%
% Only the 12 curated animals (per tdcs_config().groups) are retained;
% any other animal present in the raw export is dropped.
thisDir = fileparts(mfilename('fullpath'));
successFile = fullfile(thisDir, 'raw', 'raw_success.csv');
totalFile = fullfile(thisDir, 'raw', 'raw_total.csv');
Tsuccess = localReadMatrix(successFile, 'success');
Ttotal = localReadMatrix(totalFile, 'total');
% Left join keyed on (subject, day): every non-blank Success cell becomes
% a row; Total is looked up for the same (subject, day). Per the plan's
% Global Constraints, Total is fully populated wherever Success is, so
% this should never introduce a missing total but the join is a left
% join (not an inner join) specifically so that an unmatched Success row
% would still be kept (with total = NaN) rather than silently dropped.
T = outerjoin(Tsuccess, Ttotal, 'Keys', {'subject', 'day'}, ...
'MergeKeys', true, 'Type', 'left');
% Apply the curated map: keep only the 12 curated animals, and assign
% group from the map (overriding the file's own Group row).
cfg = tdcs_config();
subjectsCell = cellstr(T.subject);
keep = isKey(cfg.groups, subjectsCell);
T = T(keep, :);
subjectsCell = subjectsCell(keep);
groupVals = cell(height(T), 1);
for i = 1:height(T)
groupVals{i} = cfg.groups(subjectsCell{i});
end
T.group = categorical(groupVals);
% Restrict to day >= 0 (analysis window).
T = T(T.day >= 0, :);
% Final variable order and a deterministic row order.
T = T(:, {'subject', 'group', 'day', 'success', 'total'});
T = sortrows(T, {'subject', 'day'});
end
function Tlong = localReadMatrix(filePath, valueName)
%LOCALREADMATRIX Parse one app-exported day-by-subject matrix to long form.
% Line 1 = "Data,<metric>" (skipped), line 2 blank (skipped), line 3 =
% "Group,<...>" (skipped -- grouping comes from the curated map, not
% this row), line 4 = "# Days Reach,<subject headers>" (columns 2:end
% are the subject names), lines 5+ = "<day>,<value per subject>" with
% blank cells meaning "no session that day for that subject".
lines = readlines(filePath);
% Defensive: drop any wholly-blank trailing line (e.g. if the file ends
% with a newline). Real data rows always start with a day number and are
% never zero-length.
while numel(lines) > 0 && strlength(strip(lines(end))) == 0
lines(end) = [];
end
headerParts = strsplit(lines(4), ',', 'CollapseDelimiters', false);
subjects = string(headerParts(2:end));
nSubjects = numel(subjects);
dataLines = lines(5:end);
dataLines = dataLines(strlength(strip(dataLines)) > 0);
nRows = numel(dataLines);
day = nan(nRows * nSubjects, 1);
subjectCol = strings(nRows * nSubjects, 1);
value = nan(nRows * nSubjects, 1);
idx = 0;
for r = 1:nRows
parts = strsplit(dataLines(r), ',', 'CollapseDelimiters', false);
dayVal = str2double(parts(1));
for c = 1:nSubjects
col = c + 1;
if col <= numel(parts)
cellStr = strtrim(parts(col));
else
cellStr = "";
end
if strlength(cellStr) == 0
continue
end
idx = idx + 1;
day(idx) = dayVal;
subjectCol(idx) = subjects(c);
value(idx) = str2double(cellStr);
end
end
day = day(1:idx);
subjectCol = subjectCol(1:idx);
value = value(1:idx);
Tlong = table(subjectCol, day, value, 'VariableNames', {'subject', 'day', valueName});
end
+216
View File
@@ -0,0 +1,216 @@
function R = tdcs_models(S, meta, cfg)
%TDCS_MODELS Fit the tDCS GLMMs, per-animal tests, and (unmerged) classification.
% R = TDCS_MODELS(S, META, CFG) consumes one scenario table S and its
% META (from TDCS_SCENARIO_DATA) plus CFG (from TDCS_CONFIG), and
% returns a struct R:
%
% R.countGLME GeneralizedLinearMixedModel, Poisson count-level model:
% success ~ group + day_c + day_c2 + (1|subject)
% R.rateGLME GeneralizedLinearMixedModel, Binomial rate-level model
% (total==0 sessions dropped first):
% success ~ group + day_c + day_c2 + (1|subject)
% R.learnGLME GeneralizedLinearMixedModel, Poisson learning-rate
% model with a group x day_c interaction:
% success ~ group*day_c + day_c2 + (1|subject)
% R.learn struct with the joint Wald test (via COEFTEST) that
% all group x day_c interaction terms are zero:
% .jointP, .jointF, .jointDF1, .jointDF2.
% R.perAnimal per-subject Box-B2 vs Box-A2 comparison (pure stats,
% no GLME involved): .countWelchP, .countMWUp,
% .rateWelchP, .rateMWUp, .nB, .nA. "count" = per-subject
% mean success; "rate" = per-subject pooled
% sum(success)/sum(total). Welch = TTEST2(...,
% 'Vartype','unequal') (two-sided); MWU = RANKSUM(...,
% 'tail','right') (one-sided, B2 > A2).
% R.h2 anchor check (Box-A2 vs Box-B2) read off the
% count/rate GLMEs' group_<anchorLow> fixed effect
% (coded vs the cfg.ref = anchorHigh reference level):
% .countIRR, .countOneSidedP, .rateOR, .rateOneSidedP.
% IRR/OR = 1/exp(coef) is the Box-B2-over-Box-A2
% advantage (so > 1 supports H2); one-sided p =
% NORMCDF(coef/se), testing coef < 0 (B2 above A2).
% R.classify (unmerged scenarios only, i.e. meta.isUnmerged) struct
% with one field per unknown in cfg.unknowns (field name
% via matlab.lang.makeValidName), each holding .count and
% .rate sub-structs with .vsA2 (.ratio, .p), .vsB2
% (.ratio, .p), and .label (one of 'B2-like', 'A2-like',
% 'AMBIGUOUS (nearer <anchor>)', 'UNLIKE BOTH'), per
% METHODS.md's classification logic. For merged
% scenarios (meta.isUnmerged == false), R.classify is an
% empty (no-field) struct.
%
% See analysis/tdcs_glm.py (the Python this ports) and
% docs/superpowers/plans/2026-07-20-matlab-tdcs-analysis.md (Task 3).
countFormula = 'success ~ group + day_c + day_c2 + (1|subject)';
R.countGLME = fitglme(S, countFormula, 'Distribution', 'Poisson', 'Link', 'log');
Sr = S(S.total > 0, :);
R.rateGLME = fitglme(Sr, countFormula, 'Distribution', 'Binomial', ...
'BinomialSize', Sr.total, 'Link', 'logit');
learnFormula = 'success ~ group*day_c + day_c2 + (1|subject)';
R.learnGLME = fitglme(S, learnFormula, 'Distribution', 'Poisson');
R.learn = localJointInteractionTest(R.learnGLME);
R.perAnimal = localPerAnimal(S, cfg);
R.h2 = localH2(R.countGLME, R.rateGLME, cfg);
if meta.isUnmerged
R.classify = localClassifyAll(R.countGLME, R.rateGLME, cfg);
else
R.classify = struct();
end
end
% --------------------------------------------------------------- per-animal
function out = localPerAnimal(S, cfg)
%LOCALPERANIMAL Per-subject Box-B2 vs Box-A2 Welch t-test / MWU (pure stats).
b = S(S.group == cfg.anchorHigh, :);
a = S(S.group == cfg.anchorLow, :);
[bCount, bRate, nB] = localSubjectSummary(b);
[aCount, aRate, nA] = localSubjectSummary(a);
[~, countWelchP] = ttest2(bCount, aCount, 'Vartype', 'unequal');
countMWUp = ranksum(bCount, aCount, 'tail', 'right');
[~, rateWelchP] = ttest2(bRate, aRate, 'Vartype', 'unequal');
rateMWUp = ranksum(bRate, aRate, 'tail', 'right');
out = struct( ...
'countWelchP', countWelchP, 'countMWUp', countMWUp, ...
'rateWelchP', rateWelchP, 'rateMWUp', rateMWUp, ...
'nB', nB, 'nA', nA);
end
function [meanSuccess, pooledRate, nSubj] = localSubjectSummary(T)
%LOCALSUBJECTSUMMARY Per-subject mean success and pooled success/total.
subj = unique(T.subject);
nSubj = numel(subj);
meanSuccess = zeros(nSubj, 1);
pooledRate = zeros(nSubj, 1);
for i = 1:nSubj
rows = T.subject == subj(i);
meanSuccess(i) = mean(T.success(rows));
pooledRate(i) = sum(T.success(rows)) / sum(T.total(rows));
end
end
% --------------------------------------------------------------------- H2
function h2 = localH2(countGLME, rateGLME, cfg)
%LOCALH2 Anchor check: Box-A2 vs Box-B2, read off each GLME's group term.
[countCoef, countSE] = localCoefAndSE(countGLME, cfg.anchorLow, cfg.ref);
[rateCoef, rateSE] = localCoefAndSE(rateGLME, cfg.anchorLow, cfg.ref);
h2 = struct( ...
'countIRR', 1 / exp(countCoef), ...
'countOneSidedP', normcdf(countCoef / countSE), ...
'rateOR', 1 / exp(rateCoef), ...
'rateOneSidedP', normcdf(rateCoef / rateSE));
end
% -------------------------------------------------------------- classify
function out = localClassifyAll(countGLME, rateGLME, cfg)
%LOCALCLASSIFYALL Classify every unknown in cfg.unknowns vs both anchors.
out = struct();
for i = 1:numel(cfg.unknowns)
unknown = cfg.unknowns{i};
fieldName = matlab.lang.makeValidName(unknown);
out.(fieldName) = struct( ...
'count', localClassifyOne(countGLME, unknown, cfg), ...
'rate', localClassifyOne(rateGLME, unknown, cfg));
end
end
function verdict = localClassifyOne(model, unknown, cfg)
%LOCALCLASSIFYONE Compare UNKNOWN vs both anchors on one GLME; label it.
% Mirrors classify_one() in analysis/tdcs_glm.py / METHODS.md's
% "Classification logic (unknowns)": indistinguishable (p >= 0.05) from
% one anchor and different from the other => that anchor's label;
% indistinguishable from both => AMBIGUOUS (report the numerically
% nearer one); different from both => UNLIKE BOTH.
[coefLo, pLo] = localContrast(model, unknown, cfg.anchorLow, cfg.ref);
[coefHi, pHi] = localContrast(model, unknown, cfg.anchorHigh, cfg.ref);
vsA2 = struct('ratio', exp(coefLo), 'p', pLo);
vsB2 = struct('ratio', exp(coefHi), 'p', pHi);
likeLo = pLo >= 0.05;
likeHi = pHi >= 0.05;
if likeHi && ~likeLo
label = 'B2-like';
elseif likeLo && ~likeHi
label = 'A2-like';
elseif likeLo && likeHi
if abs(coefHi) < abs(coefLo)
nearer = cfg.anchorHigh;
else
nearer = cfg.anchorLow;
end
label = sprintf('AMBIGUOUS (nearer %s)', nearer);
else
label = 'UNLIKE BOTH';
end
verdict = struct('vsA2', vsA2, 'vsB2', vsB2, 'label', label);
end
% ------------------------------------------------------------- contrasts
function [coef, se] = localCoefAndSE(model, aName, refName)
%LOCALCOEFANDSE Coefficient + SE for group level A_NAME vs the reference.
v = localContrastVector(model, aName, refName, refName);
beta = model.Coefficients.Estimate;
Cov = model.CoefficientCovariance;
coef = v * beta;
se = sqrt(v * Cov * v');
end
function [coef, p] = localContrast(model, aName, bName, refName)
%LOCALCONTRAST Linear contrast A_NAME vs B_NAME (both coded vs REF_NAME).
% Two-sided p via COEFTEST (an F-test on one row = the two-sided
% Wald/t-test); works for any pair, including B_NAME == REF_NAME.
v = localContrastVector(model, aName, bName, refName);
beta = model.Coefficients.Estimate;
coef = v * beta;
p = coefTest(model, v);
end
function v = localContrastVector(model, aName, bName, refName)
%LOCALCONTRASTVECTOR +1 at group_A_NAME (if not the reference), -1 at
% group_B_NAME (if not the reference), over MODEL.CoefficientNames.
cn = model.CoefficientNames;
v = zeros(1, numel(cn));
if ~strcmp(aName, refName)
v(localCoefIndex(cn, aName)) = v(localCoefIndex(cn, aName)) + 1;
end
if ~strcmp(bName, refName)
v(localCoefIndex(cn, bName)) = v(localCoefIndex(cn, bName)) - 1;
end
end
function idx = localCoefIndex(coefNames, groupName)
%LOCALCOEFINDEX Index into CoefficientNames for a "group_<groupName>" term.
idx = find(strcmp(coefNames, ['group_' groupName]), 1);
if isempty(idx)
error('tdcs_models:missingGroupTerm', ...
'No "group_%s" coefficient in this GLME (CoefficientNames: %s).', ...
groupName, strjoin(coefNames, ', '));
end
end
% -------------------------------------------------------------- learning
function out = localJointInteractionTest(learnGLME)
%LOCALJOINTINTERACTIONTEST Joint Wald test that all group x day_c
% interaction fixed effects are zero (do groups learn at different
% rates?), mirroring the GEE wald_test in analysis/tdcs_glm.py.
cn = learnGLME.CoefficientNames;
isInteraction = contains(cn, ':day_c') & ~contains(cn, 'day_c2');
idx = find(isInteraction);
H = zeros(numel(idx), numel(cn));
for i = 1:numel(idx)
H(i, idx(i)) = 1;
end
[p, F, df1, df2] = coefTest(learnGLME, H);
out = struct('jointP', p, 'jointF', F, 'jointDF1', df1, 'jointDF2', df2);
end
+172
View File
@@ -0,0 +1,172 @@
function tdcs_report(S, R, meta, cfg, scenario)
%TDCS_REPORT Print + persist the tDCS GLM report for one scenario.
% TDCS_REPORT(S, R, META, CFG, SCENARIO) prints a three-part report to
% the console (DESCRIPTIVES, RAW GLM, INTERPRETATION) and writes the
% same text to analysis/matlab/results/<SCENARIO>.txt (the results/
% folder is created if it does not already exist).
%
% S scenario table, from TDCS_SCENARIO_DATA.
% R model results struct, from TDCS_MODELS.
% META scenario metadata struct, from TDCS_SCENARIO_DATA
% (.mergeKey, .maxDay, .isUnmerged).
% CFG config struct, from TDCS_CONFIG.
% SCENARIO scenario name (char/string), used only for the report
% header and the output filename.
%
% The INTERPRETATION section mirrors verdicts()/classify() in
% analysis/tdcs_glm.py and the "Classification logic (unknowns)" /
% "Caveats" sections of analysis/METHODS.md: the H2 anchor-check
% verdict (IRR/OR + one-sided p + SUPPORTED/not supported), the
% per-animal Welch/Mann-Whitney p-values, the unknown-condition
% classification (unmerged scenarios only), and the standing caveats
% (tiny groups, single-subject classification, count-vs-rate).
%
% See analysis/tdcs_glm.py, analysis/METHODS.md, and
% docs/superpowers/plans/2026-07-20-matlab-tdcs-analysis.md (Task 4).
scenario = char(scenario);
txt = '';
txt = [txt localHeader(sprintf('tDCS GLM report -- scenario: %s', scenario))];
txt = [txt sprintf('merge key: %s day window: 0..%d observations: %d\n', ...
meta.mergeKey, meta.maxDay, height(S))];
txt = [txt localDescriptives(S)];
txt = [txt localRawGLM(R)];
txt = [txt localInterpretation(R, meta, cfg)];
fprintf('%s', txt);
thisDir = fileparts(mfilename('fullpath'));
resultsDir = fullfile(thisDir, 'results');
if ~exist(resultsDir, 'dir')
mkdir(resultsDir);
end
outFile = fullfile(resultsDir, [scenario '.txt']);
fid = fopen(outFile, 'w');
if fid == -1
error('tdcs_report:cannotWrite', 'Could not open "%s" for writing.', outFile);
end
cleanupObj = onCleanup(@() fclose(fid)); %#ok<NASGU>
fprintf(fid, '%s', txt);
end
% ------------------------------------------------------------------ header
function s = localHeader(title)
bar = repmat('=', 1, 78);
s = sprintf('%s\n%s\n%s\n', bar, title, bar);
end
% -------------------------------------------------------------- descriptives
function s = localDescriptives(S)
s = localHeader('DESCRIPTIVES');
h = sprintf('%-20s %8s %10s %13s %10s %8s', ...
'group', 'n_subj', 'n_sessions', 'mean_success', 'mean_rate', 'max_day');
s = [s h sprintf('\n') repmat('-', 1, length(h)) sprintf('\n')];
grps = categories(S.group);
for i = 1:numel(grps)
rows = S.group == grps{i};
nSubj = numel(unique(S.subject(rows)));
nSessions = sum(rows);
meanSuccess = mean(S.success(rows));
meanRate = sum(S.success(rows)) / sum(S.total(rows));
maxDay = max(S.day(rows));
s = [s sprintf('%-20s %8d %10d %13.1f %10.3f %8d\n', ...
grps{i}, nSubj, nSessions, meanSuccess, meanRate, maxDay)]; %#ok<AGROW>
end
s = [s sprintf('\n')];
end
% ---------------------------------------------------------------- raw GLM
function s = localRawGLM(R)
s = localHeader('(A) LEVEL / COUNT -- Poisson GLMM (subject random intercept)');
s = [s localCleanDisp(R.countGLME) sprintf('\n')];
s = [s localHeader('(B) LEVEL / RATE -- Binomial GLMM (subject random intercept)')];
s = [s localCleanDisp(R.rateGLME) sprintf('\n')];
s = [s localHeader('(C) LEARNING RATE -- joint test (all group x day_c interactions = 0)')];
s = [s sprintf('F=%.3f, df1=%d, df2=%.2f, p=%.4f\n', ...
R.learn.jointF, R.learn.jointDF1, R.learn.jointDF2, R.learn.jointP)];
s = [s sprintf([' -> small p = groups improve at DIFFERENT rates; ' ...
'large p = parallel learning.\n\n'])];
end
function s = localCleanDisp(model)
%LOCALCLEANDISP disp(MODEL) captured to text, with the Command-Window-only
% <strong>...</strong> bold-markup tags (rendered by the interactive
% MATLAB terminal, but emitted as literal text by EVALC) stripped so the
% report/console/file output is plain text.
raw = evalc('disp(model)');
s = regexprep(raw, '</?strong>', '');
end
% ------------------------------------------------------------ interpretation
function s = localInterpretation(R, meta, cfg)
s = localHeader('INTERPRETATION');
s = [s sprintf(['Note: these are subject-level GLMMs (Laplace-approximated ' ...
'fitglme), not the\nPython reference''s population-average GEE -- ' ...
'directions/magnitudes should agree,\nexact ratios and p-values will ' ...
'differ.\n\n'])];
s = [s sprintf('Anchor check -- H2: Box-B2 BETTER than Box-A2 (the two anchors must differ)\n')];
s = [s localH2Line('count/level', R.h2.countIRR, R.h2.countOneSidedP)];
s = [s localH2Line('rate/level ', R.h2.rateOR, R.h2.rateOneSidedP)];
s = [s sprintf('\nPer-animal (Box-B2 n=%d vs Box-A2 n=%d), pure stats (no GLME):\n', ...
R.perAnimal.nB, R.perAnimal.nA)];
s = [s sprintf(' count (per-subject mean success): Welch two-sided p=%.4f, Mann-Whitney one-sided (B2>A2) p=%.4f\n', ...
R.perAnimal.countWelchP, R.perAnimal.countMWUp)];
s = [s sprintf(' rate (per-subject pooled success/total): Welch two-sided p=%.4f, Mann-Whitney one-sided (B2>A2) p=%.4f\n', ...
R.perAnimal.rateWelchP, R.perAnimal.rateMWUp)];
if meta.isUnmerged
s = [s sprintf('\nClassification -- is each UNKNOWN condition A2-like or B2-like?\n')];
s = [s sprintf('(ratio >1 = above that anchor; p = differs from that anchor)\n')];
for i = 1:numel(cfg.unknowns)
unknown = cfg.unknowns{i};
fieldName = matlab.lang.makeValidName(unknown);
entry = R.classify.(fieldName);
s = [s sprintf('\n %s:\n', unknown)]; %#ok<AGROW>
s = [s localClassifyLine('count/level', entry.count)]; %#ok<AGROW>
s = [s localClassifyLine('rate/level ', entry.rate)]; %#ok<AGROW>
end
s = [s sprintf(['\n NOTE: each unknown has ONLY 1 subject. ''Matches'' means ' ...
'''not statistically\n distinguishable'', weak evidence at n=1, not proof ' ...
'of equivalence.\n'])];
else
s = [s sprintf(['\n(No unknown groups -- they were merged into the anchors; ' ...
'only the H2 anchor\ncontrast applies.)\n'])];
end
s = [s sprintf('\n') localHeader('CAVEATS')];
s = [s sprintf([' - Tiny groups: each arm has only n=3-4 subjects (Naive n=4; ' ...
'anchor arms n=3-5\n depending on merge), and -- in unmerged scenarios -- ' ...
'each unknown condition\n (Electrode-Box-A, Right-Electrode) has only ' ...
'n=1 subject. Treat every group\n comparison here as preliminary.\n'])];
s = [s sprintf([' - Single-subject classification: for a 1-subject unknown, ' ...
'''matches anchor X''\n means ''not statistically distinguishable from X'', ' ...
'NOT proof of equivalence;\n inference with a single subject in a group ' ...
'is fragile.\n'])];
s = [s sprintf([' - Count vs rate: ''success'' alone is a raw count; the rate ' ...
'model\n (success/attempts) is the fairer accuracy comparison when attempt ' ...
'counts differ\n between groups.\n'])];
end
function s = localH2Line(label, ratio, p)
tag = 'not supported';
if ratio > 1 && p < 0.05
tag = 'SUPPORTED';
end
s = sprintf(' [%s] Box-B2 = %.2fx Box-A2 (one-sided p=%.4f) -> %s\n', ...
label, ratio, p, tag);
end
function s = localClassifyLine(label, verdict)
s = sprintf([' [%s] vs Box-A2: %.2fx p=%.3f vs Box-B2: %.2fx p=%.3f\n' ...
' -> %s\n'], ...
label, verdict.vsA2.ratio, verdict.vsA2.p, ...
verdict.vsB2.ratio, verdict.vsB2.p, verdict.label);
end
+113
View File
@@ -0,0 +1,113 @@
function [S, meta] = tdcs_scenario_data(scenario)
%TDCS_SCENARIO_DATA Build one merge/window scenario from the curated tDCS data.
% [S, META] = TDCS_SCENARIO_DATA(SCENARIO) loads the curated 12-animal
% table via TDCS_LOAD_DATA, applies the requested group-merge map from
% TDCS_CONFIG, restricts to day <= maxDay per the scenario's window
% suffix, adds mean-centered day covariates DAY_C / DAY_C2, and recodes
% GROUP as a categorical with CFG.REF ('Electrode-Box-B2') as the FIRST
% (reference) category via REORDERCATS. S is also written to
% analysis/matlab/derived/<SCENARIO>.csv via WRITETABLE (the derived/
% folder is created if it does not already exist).
%
% SCENARIO must be one of:
% unmerged_full, unmerged_d0_10, mergeA2_full, mergeA2_d0_10,
% mergeB2_full, mergeB2_d0_10
%
% The scenario name is split into:
% - a merge key: 'unmerged' | 'mergeA2' | 'mergeB2', selecting which
% containers.Map from TDCS_CONFIG (if any) to apply to relabel
% GROUP. 'unmerged' applies no relabeling (identity) -- it is not
% backed by a config map at all, since only the two "unknown"
% conditions (Electrode-Box-A, Right-Electrode) are ever merge
% targets, and the unmerged scenario reports them unmerged/as-is.
% - a day window: '_full' -> maxDay = 26, '_d0_10' -> maxDay = 10.
%
% META is a struct with fields:
% .mergeKey 'unmerged' | 'mergeA2' | 'mergeB2'
% .maxDay 26 or 10
% .isUnmerged true only when mergeKey == 'unmerged'
[mergeKey, maxDay] = localParseScenario(scenario);
cfg = tdcs_config();
T = tdcs_load_data();
switch mergeKey
case 'unmerged'
% Identity: no relabeling. An empty map means isKey(...) is
% always false below, so every group passes through unchanged.
mergeMap = containers.Map('KeyType', 'char', 'ValueType', 'char');
case 'mergeA2'
mergeMap = cfg.mergeA2;
case 'mergeB2'
mergeMap = cfg.mergeB2;
otherwise
error('tdcs_scenario_data:badScenario', ...
'Unrecognized scenario "%s".', scenario);
end
% Relabel GROUP per the merge map. Groups that are not a key in the map
% (e.g. Naive, Electrode-Box-A2, Electrode-Box-B2 under mergeA2/mergeB2,
% or every group under the empty 'unmerged' map) are left unchanged --
% only the two "unknown" conditions are ever merge-map keys.
groupCell = cellstr(T.group);
for i = 1:numel(groupCell)
if isKey(mergeMap, groupCell{i})
groupCell{i} = mergeMap(groupCell{i});
end
end
% Day window (applied after relabeling, before centering, per the plan).
dayMask = T.day <= maxDay;
S = T(dayMask, :);
groupCell = groupCell(dayMask);
% Recode GROUP as categorical with cfg.ref first (the GLME reference
% level). Only categories actually present after merge+window survive.
S.group = categorical(groupCell);
allCats = categories(S.group);
assert(ismember(cfg.ref, allCats), 'tdcs_scenario_data:refMissing', ...
'Reference group "%s" is absent from scenario "%s".', cfg.ref, char(scenario));
otherCats = allCats(~strcmp(allCats, cfg.ref));
S.group = reordercats(S.group, [{cfg.ref}; otherCats]);
% Mean-centered day covariates, computed on the post-window data.
S.day_c = S.day - mean(S.day);
S.day_c2 = S.day_c .^ 2;
% Persist the derived scenario table.
thisDir = fileparts(mfilename('fullpath'));
derivedDir = fullfile(thisDir, 'derived');
if ~exist(derivedDir, 'dir')
mkdir(derivedDir);
end
outFile = fullfile(derivedDir, [char(scenario) '.csv']);
writetable(S, outFile);
meta = struct();
meta.mergeKey = mergeKey;
meta.maxDay = maxDay;
meta.isUnmerged = strcmp(mergeKey, 'unmerged');
end
function [mergeKey, maxDay] = localParseScenario(scenario)
%LOCALPARSESCENARIO Split "<mergeKey>_full" / "<mergeKey>_d0_10" apart.
scenario = char(scenario);
if endsWith(scenario, '_full')
maxDay = 26;
mergeKey = scenario(1:end - numel('_full'));
elseif endsWith(scenario, '_d0_10')
maxDay = 10;
mergeKey = scenario(1:end - numel('_d0_10'));
else
error('tdcs_scenario_data:badScenario', ...
'Scenario "%s" does not end in "_full" or "_d0_10".', scenario);
end
if ~ismember(mergeKey, {'unmerged', 'mergeA2', 'mergeB2'})
error('tdcs_scenario_data:badScenario', ...
'Scenario "%s" has unrecognized merge key "%s".', scenario, mergeKey);
end
end
+72
View File
@@ -0,0 +1,72 @@
classdef tLoadData < matlab.unittest.TestCase
%TLOADDATA Tests for tdcs_load_data / tdcs_config (Task 1).
%
% Authored per TDD: written before tdcs_load_data.m existed, so it is
% expected to fail until the loader is implemented. As of this
% commit MATLAB is not licensed in this environment, so this suite
% has been authored and reviewed by hand but has NOT been executed.
% Expected values (row count, group counts, load-check cells) were
% independently verified against analysis/matlab/raw/*.csv by a
% throwaway Python script (see task-1-report.md), not by running
% this test.
properties (Constant)
% Non-blank (subject, day) success cells for the 12 curated
% animals with day >= 0, counted directly from raw_success.csv.
ExpectedRowCount = 185
end
methods (Test)
function testTwelveCuratedSubjects(testCase)
T = tdcs_load_data();
testCase.verifyEqual(numel(unique(T.subject)), 12);
end
function testRowCountMatchesNonBlankDayGeq0Cells(testCase)
T = tdcs_load_data();
testCase.verifyEqual(height(T), tLoadData.ExpectedRowCount);
end
function testGroupSubjectCounts(testCase)
T = tdcs_load_data();
subjGroup = unique(T(:, {'subject', 'group'}));
counts = countcats(subjGroup.group);
catNames = categories(subjGroup.group);
expected = containers.Map( ...
{'Naive', 'Electrode-Box-A2', 'Electrode-Box-B2', ...
'Electrode-Box-A', 'Right-Electrode'}, ...
{4, 3, 3, 1, 1});
testCase.verifyEqual(numel(catNames), double(expected.Count));
for i = 1:numel(catNames)
name = catNames{i};
testCase.verifyTrue(isKey(expected, name), ...
sprintf('Unexpected group "%s" in loaded data.', name));
testCase.verifyEqual(counts(i), expected(name), ...
sprintf('Subject count mismatch for group "%s".', name));
end
end
function testVuVuongDayZeroLoadCheck(testCase)
T = tdcs_load_data();
row = T(T.subject == "Vu-vuong" & T.day == 0, {'success', 'total'});
testCase.verifyEqual(height(row), 1);
testCase.verifyEqual(row.success(1), 18);
testCase.verifyEqual(row.total(1), 61);
end
function testBanhMi1DayZeroLoadCheck(testCase)
T = tdcs_load_data();
row = T(T.subject == "Banh-mi-1" & T.day == 0, {'success', 'total'});
testCase.verifyEqual(height(row), 1);
testCase.verifyEqual(row.success(1), 13);
testCase.verifyEqual(row.total(1), 84);
end
function testNoNegativeDayRows(testCase)
T = tdcs_load_data();
testCase.verifyTrue(all(T.day >= 0));
end
end
end
+121
View File
@@ -0,0 +1,121 @@
classdef tModels < matlab.unittest.TestCase
%TMODELS Tests for tdcs_models (Task 3: GLMM + per-animal + classify).
%
% Authored per TDD: written before tdcs_models.m existed, so it is
% expected to fail until the model-fitting function is implemented.
% MATLAB R2025b + Statistics and Machine Learning Toolbox IS licensed
% in this environment (unlike Tasks 1-2), so this suite is actually
% run: RED before tdcs_models.m existed, GREEN after.
%
% Per-animal golden values (Box-B2 vs Box-A2, pure stats: per-subject
% mean success for "count", per-subject pooled sum(success)/sum(total)
% for "rate"; Welch two-sided TTEST2, one-sided 'right' RANKSUM) were
% independently reproduced with a throwaway Python script against
% analysis/tdcs_reach_data.csv (equal_var=False t-test,
% mannwhitneyu(..., alternative='greater')), confirming the plan's
% Task 3 golden-value table to better than 1e-3 in every cell (see
% task-3-report.md). They must match to 3 decimals (AbsTol 0.005) --
% do not loosen this tolerance.
%
% GLME-derived checks (H2 odds/incidence-rate ratios, classification)
% are checked loosely (direction/magnitude), since GLMM (subject
% random intercept) is not numerically identical to the Python's GEE
% (population-average, robust SEs) -- see the plan's Global
% Constraints and analysis/METHODS.md.
properties (TestParameter)
perAnimalCase = struct( ...
'unmerged_full', struct( ...
'scenario', 'unmerged_full', 'nB', 3, 'nA', 3, ...
'rateWelchP', 0.072, 'rateMWUp', 0.050, ...
'countWelchP', 0.055, 'countMWUp', 0.050), ...
'mergeA2_full', struct( ...
'scenario', 'mergeA2_full', 'nB', 4, 'nA', 4, ...
'rateWelchP', 0.056, 'rateMWUp', 0.029, ...
'countWelchP', 0.035, 'countMWUp', 0.029), ...
'mergeB2_full', struct( ...
'scenario', 'mergeB2_full', 'nB', 5, 'nA', 3, ...
'rateWelchP', 0.044, 'rateMWUp', 0.018, ...
'countWelchP', 0.020, 'countMWUp', 0.018), ...
'unmerged_d0_10', struct( ...
'scenario', 'unmerged_d0_10', 'nB', 3, 'nA', 3, ...
'rateWelchP', 0.070, 'rateMWUp', 0.050, ...
'countWelchP', 0.041, 'countMWUp', 0.050), ...
'mergeA2_d0_10', struct( ...
'scenario', 'mergeA2_d0_10', 'nB', 4, 'nA', 4, ...
'rateWelchP', 0.069, 'rateMWUp', 0.057, ...
'countWelchP', 0.023, 'countMWUp', 0.014), ...
'mergeB2_d0_10', struct( ...
'scenario', 'mergeB2_d0_10', 'nB', 5, 'nA', 3, ...
'rateWelchP', 0.094, 'rateMWUp', 0.071, ...
'countWelchP', 0.023, 'countMWUp', 0.018) ...
)
end
methods (Test)
function testPerAnimalGoldenValues(testCase, perAnimalCase)
cfg = tdcs_config();
[S, meta] = tdcs_scenario_data(perAnimalCase.scenario);
R = tdcs_models(S, meta, cfg);
testCase.verifyEqual(R.perAnimal.nB, perAnimalCase.nB);
testCase.verifyEqual(R.perAnimal.nA, perAnimalCase.nA);
testCase.verifyEqual(R.perAnimal.rateWelchP, ...
perAnimalCase.rateWelchP, 'AbsTol', 0.005);
testCase.verifyEqual(R.perAnimal.rateMWUp, ...
perAnimalCase.rateMWUp, 'AbsTol', 0.005);
testCase.verifyEqual(R.perAnimal.countWelchP, ...
perAnimalCase.countWelchP, 'AbsTol', 0.005);
testCase.verifyEqual(R.perAnimal.countMWUp, ...
perAnimalCase.countMWUp, 'AbsTol', 0.005);
end
function testGLMEObjectsReturned(testCase)
cfg = tdcs_config();
[S, meta] = tdcs_scenario_data('mergeB2_full');
R = tdcs_models(S, meta, cfg);
testCase.verifyClass(R.countGLME, 'GeneralizedLinearMixedModel');
testCase.verifyClass(R.rateGLME, 'GeneralizedLinearMixedModel');
testCase.verifyClass(R.learnGLME, 'GeneralizedLinearMixedModel');
end
function testH2SanityMergeB2Full(testCase)
cfg = tdcs_config();
[S, meta] = tdcs_scenario_data('mergeB2_full');
R = tdcs_models(S, meta, cfg);
testCase.verifyGreaterThan(R.h2.rateOR, 1.3);
testCase.verifyLessThan(R.h2.rateOR, 1.7);
testCase.verifyLessThan(R.h2.rateOneSidedP, 0.05);
testCase.verifyGreaterThan(R.h2.countIRR, 1);
end
function testClassifyPresentForUnmergedOnly(testCase)
cfg = tdcs_config();
[S, meta] = tdcs_scenario_data('unmerged_full');
R = tdcs_models(S, meta, cfg);
testCase.verifyTrue(meta.isUnmerged);
for i = 1:numel(cfg.unknowns)
fieldName = matlab.lang.makeValidName(cfg.unknowns{i});
testCase.verifyTrue(isfield(R.classify, fieldName), ...
sprintf('R.classify missing entry for unknown "%s".', ...
cfg.unknowns{i}));
entry = R.classify.(fieldName);
testCase.verifyTrue(isfield(entry, 'count'));
testCase.verifyTrue(isfield(entry, 'rate'));
testCase.verifyTrue(isfield(entry.count, 'label'));
testCase.verifyTrue(isfield(entry.count.vsA2, 'ratio'));
testCase.verifyTrue(isfield(entry.count.vsA2, 'p'));
testCase.verifyTrue(isfield(entry.count.vsB2, 'ratio'));
testCase.verifyTrue(isfield(entry.count.vsB2, 'p'));
end
[Sm, metam] = tdcs_scenario_data('mergeB2_full');
Rm = tdcs_models(Sm, metam, cfg);
testCase.verifyFalse(metam.isUnmerged);
testCase.verifyEmpty(fieldnames(Rm.classify));
end
end
end
+51
View File
@@ -0,0 +1,51 @@
classdef tReport < matlab.unittest.TestCase
%TREPORT Tests for tdcs_glm / tdcs_report (Task 4: entry point + report).
%
% MATLAB R2025b + Statistics and Machine Learning Toolbox IS licensed
% in this environment, so this suite is actually run. It does not
% re-check golden values (tModels already does that); it checks the
% wiring: tdcs_glm(scenario) runs end-to-end without error and writes
% results/<scenario>.txt, and the otherwise-branch errors on an
% unrecognized scenario name.
methods (Test)
function testRunsScenarioAndWritesResultFile(testCase)
thisDir = fileparts(mfilename('fullpath'));
resultsDir = fullfile(fileparts(thisDir), 'results');
outFile = fullfile(resultsDir, 'mergeB2_full.txt');
if exist(outFile, 'file')
delete(outFile);
end
tdcs_glm('mergeB2_full');
testCase.verifyTrue(exist(outFile, 'file') == 2, ...
'tdcs_glm did not write results/mergeB2_full.txt.');
txt = fileread(outFile);
testCase.verifyNotEmpty(txt);
testCase.verifyTrue(contains(txt, 'DESCRIPTIVES'));
testCase.verifyTrue(contains(txt, 'INTERPRETATION'));
testCase.verifyTrue(contains(txt, 'CAVEATS'));
% Command-Window-only markup must be stripped from the report.
testCase.verifyFalse(contains(txt, '<strong>'));
end
function testUnmergedScenarioIncludesClassification(testCase)
thisDir = fileparts(mfilename('fullpath'));
resultsDir = fullfile(fileparts(thisDir), 'results');
outFile = fullfile(resultsDir, 'unmerged_full.txt');
tdcs_glm('unmerged_full');
txt = fileread(outFile);
testCase.verifyTrue(contains(txt, 'Classification'));
testCase.verifyTrue(contains(txt, 'Electrode-Box-A'));
testCase.verifyTrue(contains(txt, 'Right-Electrode'));
end
function testBadScenarioErrors(testCase)
testCase.verifyError(@() tdcs_glm('not_a_real_scenario'), ...
'tdcs_glm:badScenario');
end
end
end
+135
View File
@@ -0,0 +1,135 @@
classdef tScenarioData < matlab.unittest.TestCase
%TSCENARIODATA Tests for tdcs_scenario_data (Task 2).
%
% Authored per TDD: written before tdcs_scenario_data.m existed, so
% it is expected to fail until the function is implemented. As of
% this commit MATLAB is not licensed in this environment, so this
% suite has been authored and reviewed by hand but has NOT been
% executed. Expected per-scenario subject-per-group counts were
% hand-derived from the Task 1 curated counts (Naive=4,
% Electrode-Box-A2=3, Electrode-Box-B2=3, Electrode-Box-A=1,
% Right-Electrode=1) plus each scenario's merge map, and cross-
% checked against task-2-brief.md's table (see task-2-report.md).
properties (TestParameter)
scenarioCase = struct( ...
'unmerged_full', struct( ...
'scenario', 'unmerged_full', ...
'maxDay', 26, ...
'counts', containers.Map( ...
{'Electrode-Box-A', 'Electrode-Box-A2', ...
'Electrode-Box-B2', 'Naive', 'Right-Electrode'}, ...
{1, 3, 3, 4, 1})), ...
'unmerged_d0_10', struct( ...
'scenario', 'unmerged_d0_10', ...
'maxDay', 10, ...
'counts', containers.Map( ...
{'Electrode-Box-A', 'Electrode-Box-A2', ...
'Electrode-Box-B2', 'Naive', 'Right-Electrode'}, ...
{1, 3, 3, 4, 1})), ...
'mergeA2_full', struct( ...
'scenario', 'mergeA2_full', ...
'maxDay', 26, ...
'counts', containers.Map( ...
{'Electrode-Box-A2', 'Electrode-Box-B2', 'Naive'}, ...
{4, 4, 4})), ...
'mergeA2_d0_10', struct( ...
'scenario', 'mergeA2_d0_10', ...
'maxDay', 10, ...
'counts', containers.Map( ...
{'Electrode-Box-A2', 'Electrode-Box-B2', 'Naive'}, ...
{4, 4, 4})), ...
'mergeB2_full', struct( ...
'scenario', 'mergeB2_full', ...
'maxDay', 26, ...
'counts', containers.Map( ...
{'Electrode-Box-A2', 'Electrode-Box-B2', 'Naive'}, ...
{3, 5, 4})), ...
'mergeB2_d0_10', struct( ...
'scenario', 'mergeB2_d0_10', ...
'maxDay', 10, ...
'counts', containers.Map( ...
{'Electrode-Box-A2', 'Electrode-Box-B2', 'Naive'}, ...
{3, 5, 4})) ...
)
end
methods (Test)
function testGroupCountsWindowAndReference(testCase, scenarioCase)
[S, meta] = tdcs_scenario_data(scenarioCase.scenario);
% Day window.
testCase.verifyEqual(meta.maxDay, scenarioCase.maxDay);
testCase.verifyLessThanOrEqual(max(S.day), scenarioCase.maxDay);
% meta fields derived from the scenario name.
expectedMergeKey = regexprep(scenarioCase.scenario, '_full$|_d0_10$', '');
testCase.verifyEqual(meta.mergeKey, expectedMergeKey);
testCase.verifyEqual(meta.isUnmerged, strcmp(expectedMergeKey, 'unmerged'));
% Reference (first) category of S.group is Electrode-Box-B2.
allCats = categories(S.group);
testCase.verifyEqual(allCats{1}, 'Electrode-Box-B2');
% Per-group subject counts.
actualCounts = tScenarioData.localSubjectCountsByGroup(S);
expectedCounts = scenarioCase.counts;
testCase.verifyEqual(actualCounts.Count, expectedCounts.Count);
actualNames = keys(actualCounts);
for i = 1:numel(actualNames)
name = actualNames{i};
testCase.verifyTrue(isKey(expectedCounts, name), ...
sprintf('Unexpected group "%s" in scenario "%s".', ...
name, scenarioCase.scenario));
testCase.verifyEqual(actualCounts(name), expectedCounts(name), ...
sprintf('Subject count mismatch for group "%s" in scenario "%s".', ...
name, scenarioCase.scenario));
end
end
function testMergeB2FullCsvIsWrittenAndReloads(testCase)
[S, ~] = tdcs_scenario_data('mergeB2_full');
thisDir = fileparts(mfilename('fullpath'));
matlabDir = fileparts(thisDir);
csvFile = fullfile(matlabDir, 'derived', 'mergeB2_full.csv');
testCase.verifyTrue(isfile(csvFile), ...
'derived/mergeB2_full.csv was not written.');
Sreload = readtable(csvFile);
Sreload.group = categorical(Sreload.group);
countsOrig = tScenarioData.localSubjectCountsByGroup(S);
countsReload = tScenarioData.localSubjectCountsByGroup(Sreload);
testCase.verifyEqual(countsReload.Count, countsOrig.Count);
namesOrig = keys(countsOrig);
for i = 1:numel(namesOrig)
name = namesOrig{i};
testCase.verifyTrue(isKey(countsReload, name), ...
sprintf('Group "%s" missing after CSV reload.', name));
testCase.verifyEqual(countsReload(name), countsOrig(name), ...
sprintf('Subject count mismatch for group "%s" after CSV reload.', name));
end
end
end
methods (Static)
function m = localSubjectCountsByGroup(T)
%LOCALSUBJECTCOUNTSBYGROUP containers.Map group name -> #unique subjects.
subjGroup = unique(T(:, {'subject', 'group'}));
group = subjGroup.group;
if ~iscategorical(group)
group = categorical(group);
end
counts = countcats(group);
catNames = categories(group);
m = containers.Map('KeyType', 'char', 'ValueType', 'double');
for i = 1:numel(catNames)
m(catNames{i}) = counts(i);
end
end
end
end
+440
View File
@@ -0,0 +1,440 @@
#!/usr/bin/env python3
"""
tDCS reaching study — GLM hypothesis tests.
QUESTION
Two KNOWN conditions anchor the scale and differ from each other:
H2 (anchor check): Electrode-Box-B2 performs BETTER than Electrode-Box-A2.
Two UNKNOWN conditions then get CLASSIFIED against those anchors:
For "Electrode-Box-A" and for "Right-Electrode", is each one
A2-like or B2-like? (We do not assume either belongs to B2.)
Each unknown is compared to BOTH anchors; a group it cannot be
distinguished from is its likely class.
Each comparison is examined through THREE complementary GLMs:
(A) LEVEL / count Poisson GEE on `success` (successful reaches per
session), adjusting for training day. Answers
"who makes more successful reaches overall?"
(B) LEVEL / rate Binomial GLM on success / attempts
(`success` out of `total`), cluster-robust.
Controls for differing numbers of attempts —
"who is more accurate per attempt?"
(C) LEARNING RATE Poisson GEE with a group x day interaction.
Answers "do the groups improve at different
RATES?" (slope of the learning curve).
All models adjust for time with day (and day^2 for the plateau) and account
for repeated measures on each subject via GEE (subject = cluster,
exchangeable correlation, robust SE) or cluster-robust standard errors.
Group is coded with Electrode-Box-B2 as the REFERENCE, so H1 and H2 read
directly off the group terms:
Right-Electrode term -> H1 (expect ~0 / non-significant)
Electrode-Box-A2 term -> H2 (expect < 0: B2 above A2)
exp(coef) is an incidence-rate ratio (count model) or odds ratio (rate
model) relative to Box-B2.
CAVEATS
* Tiny groups: Box-A2 n=3, Box-B2 n=3, Naive n=4, and each UNKNOWN
(Electrode-Box-A, Right-Electrode) has only n=1 SUBJECT. Classifying a
1-subject condition is weak: "matches anchor X" means "not statistically
distinguishable from X", NOT proof of equivalence, and cluster-robust
inference with a single cluster in a group is fragile (can show
artificially small SEs, especially in the learning-rate interaction).
DATA PROVENANCE
analysis/tdcs_reach_data.csv was reconstructed from the experiment
database (experiment "tDCS", outcome = analysis_summary.counts.Success,
attempts = analysis_summary.total, x-axis = the "# Days Reach" field) and
verified cell-by-cell against the exported matrix (71/71 unambiguous
cells on days 0-5 matched exactly).
USAGE
python3 analysis/tdcs_glm.py
Requires: pandas, numpy, scipy, statsmodels, matplotlib.
Writes: analysis/tdcs_learning_curves.png
"""
import os
import argparse
import numpy as np
import pandas as pd
import patsy
import statsmodels.api as sm
import statsmodels.formula.api as smf
from scipy import stats
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
REF = "Electrode-Box-B2"
# Two KNOWN anchor conditions that differ (Box-B2 > Box-A2, see H2).
ANCHOR_LOW = "Electrode-Box-A2"
ANCHOR_HIGH = "Electrode-Box-B2"
# Two UNKNOWN conditions to classify: is each one A2-like or B2-like?
CANON_UNKNOWNS = ["Electrode-Box-A", "Right-Electrode"]
ALL_GROUPS = ["Naive", ANCHOR_LOW, ANCHOR_HIGH] + CANON_UNKNOWNS
# The active group config is set at runtime (after any --merge/--assign remap).
UNKNOWNS = list(CANON_UNKNOWNS)
MAIN = list(ALL_GROUPS)
OTHERS = [g for g in MAIN if g != REF]
# --merge preset: assume each unknown resolves into an anchor.
MERGE_MAP = {"Electrode-Box-A": ANCHOR_LOW, "Right-Electrode": ANCHOR_HIGH}
HERE = os.path.dirname(os.path.abspath(__file__))
DATA = os.path.join(HERE, "tdcs_reach_data.csv")
PLOT = os.path.join(HERE, "tdcs_learning_curves.png")
RHS = f"C(group, Treatment('{REF}')) + day_c + day_c2"
def gterm(name):
"""statsmodels/patsy column name for a group level (vs the reference)."""
return f"C(group, Treatment('{REF}'))[T.{name}]"
def xterm(name):
"""Interaction column name (group level x day slope)."""
return f"C(group, Treatment('{REF}'))[T.{name}]:day_c"
def load(max_day=None, mapping=None):
df = pd.read_csv(DATA)
if mapping: # remap unknown -> target group
df["group"] = df["group"].map(lambda g: mapping.get(g, g))
df = df[df["group"].isin(ALL_GROUPS)].copy() # keep canonical groups
df = df[df["day"] >= 0].copy() # training days only (day 0..)
if max_day is not None:
df = df[df["day"] <= max_day].copy() # restrict the analysis window
df["day_c"] = df["day"] - df["day"].mean() # center day
df["day_c2"] = df["day_c"] ** 2
present = [g for g in ALL_GROUPS if g != REF and g in set(df["group"])]
df["group"] = pd.Categorical(df["group"], categories=[REF] + present)
return df
def contrast(res, tname):
"""coef, robust SE, p, effect=exp(coef) and its 95% CI for one term."""
ci = res.conf_int()
lo, hi = ci.loc[tname]
return dict(coef=res.params[tname], se=res.bse[tname], p=res.pvalues[tname],
eff=np.exp(res.params[tname]), lo=np.exp(lo), hi=np.exp(hi))
def one_sided_p_below(c):
"""One-sided p for H: coef < 0 (i.e. Box-B2 above the compared group)."""
return stats.norm.cdf(c["coef"] / c["se"])
def diff_contrast(res, a, b):
"""Compare group `a` vs group `b` (both coded relative to REF) via a linear
contrast. Returns coef, robust SE, p, effect=exp(coef)=ratio a/b, and CI.
Works for any pair, including b == REF (then it is just the `a` term)."""
names = list(res.params.index)
v = np.zeros(len(names))
if a != REF:
v[names.index(gterm(a))] += 1.0
if b != REF:
v[names.index(gterm(b))] -= 1.0
tt = res.t_test(v)
coef = float(np.ravel(tt.effect)[0])
se = float(np.ravel(tt.sd)[0])
p = float(np.ravel(tt.pvalue)[0])
lo, hi = np.ravel(tt.conf_int())[:2]
return dict(coef=coef, se=se, p=p, eff=np.exp(coef),
lo=np.exp(lo), hi=np.exp(hi))
# ----------------------------------------------------------------------------- models
def fit_count_level(df):
"""(A) Poisson GEE on success counts; group main effects vs Box-B2."""
return smf.gee(f"success ~ {RHS}", groups="subject", data=df,
family=sm.families.Poisson(),
cov_struct=sm.cov_struct.Exchangeable()).fit()
def fit_rate_level(df):
"""(B) Binomial GLM on success/attempts with cluster-robust SE.
Zero-attempt sessions (total==0) carry no rate information and are dropped."""
d = df[df["total"] > 0].copy()
dropped = len(df) - len(d)
if dropped:
print(f"[rate model] dropped {dropped} zero-attempt session(s) (undefined rate)")
X = patsy.dmatrix(RHS, d, return_type="dataframe")
endog = np.column_stack([d["success"].values,
(d["total"] - d["success"]).values])
return sm.GLM(endog, X, family=sm.families.Binomial()).fit(
cov_type="cluster", cov_kwds={"groups": d["subject"].values})
def fit_learning_rate(df):
"""(C) Poisson GEE with group x day interaction (slope differences)."""
return smf.gee(
f"success ~ C(group, Treatment('{REF}')) * day_c + day_c2",
groups="subject", data=df, family=sm.families.Poisson(),
cov_struct=sm.cov_struct.Exchangeable()).fit()
def fit_mixed_count(df):
"""Subject random-intercept Poisson mixed model (MAP / Laplace).
Adds a per-subject random intercept so each subject has its own baseline
level; the group fixed effects are then estimated after allowing for that
individual variation. A subject-specific complement to the population-
average GEE. statsmodels has no frequentist Poisson GLMM, so this is the
posterior-mode (MAP) fit with a Laplace covariance for the fixed effects
(fit_vb failed to converge on these large counts; MAP is stable)."""
import warnings
from statsmodels.genmod.bayes_mixed_glm import PoissonBayesMixedGLM
vc = {"subject": "0 + C(subject)"} # random intercept per subject
model = PoissonBayesMixedGLM.from_formula(f"success ~ {RHS}", vc, df)
with warnings.catch_warnings():
# MAP stops at |gradient|~5e-5 (effectively converged); silence the
# over-strict "did not converge" notice.
warnings.filterwarnings("ignore", message="Laplace fitting did not converge")
return model.fit_map()
def mixed_contrast(res, tname):
"""coef, SD, approx p and IRR + 95% interval from a BayesMixedGLM fit."""
names = list(res.model.exog_names)
i = names.index(tname)
mean, sd = float(res.fe_mean[i]), float(res.fe_sd[i])
p = 2.0 * stats.norm.sf(abs(mean / sd))
return dict(coef=mean, se=sd, p=p, eff=np.exp(mean),
lo=np.exp(mean - 1.96 * sd), hi=np.exp(mean + 1.96 * sd))
# ----------------------------------------------------------------------------- reports
def describe(df):
print("=" * 78)
print("DESCRIPTIVES")
print("=" * 78)
grp = df.groupby("group", observed=True)
tbl = pd.DataFrame({
"n_subj": grp["subject"].nunique(),
"n_sessions": grp.size(),
"mean_success": grp["success"].mean().round(1),
"mean_rate": (grp["success"].sum() / grp["total"].sum()).round(3),
"max_day": grp["day"].max(),
})
print(tbl.to_string())
def report_level(title, res, effect_label):
print("\n" + "=" * 78)
print(title)
print("=" * 78)
print(f"Effect vs {REF} ({effect_label} relative to Box-B2):")
h = f"{'group':20s} {'effect':>7s} {'95% CI':>16s} {'coef':>8s} {'SE':>7s} {'p':>9s}"
print(h)
print("-" * len(h))
out = {}
for g in OTHERS:
c = contrast(res, gterm(g))
out[g] = c
ci = f"[{c['lo']:.2f}, {c['hi']:.2f}]"
print(f"{g:20s} {c['eff']:7.3f} {ci:>16s} {c['coef']:8.3f} {c['se']:7.3f} {c['p']:9.4f}")
return out
def report_learning(res):
print("\n" + "=" * 78)
print("(C) LEARNING RATE — Poisson GEE, group x day interaction")
print("=" * 78)
base = res.params["day_c"]
print(f"Box-B2 learning slope: {np.exp(base):.3f}x successes per training day "
f"(baseline).")
print("Slope DIFFERENCE vs Box-B2 (exp(coef) = per-day multiplier on the rate ratio):")
h = f"{'group':20s} {'slopeΔ/day':>10s} {'coef':>8s} {'SE':>7s} {'p':>9s}"
print(h)
print("-" * len(h))
for g in OTHERS:
c = contrast(res, xterm(g))
print(f"{g:20s} {c['eff']:10.3f} {c['coef']:8.3f} {c['se']:7.3f} {c['p']:9.4f}")
# Joint Wald test: do ANY groups differ from Box-B2 in learning rate?
names = list(res.params.index)
idx = [names.index(xterm(g)) for g in OTHERS]
R = np.zeros((len(idx), len(names)))
for i, j in enumerate(idx):
R[i, j] = 1.0
jt = res.wald_test(R, scalar=True)
print(f"\nJoint test (all group x day interactions = 0): "
f"chi2={float(jt.statistic):.2f}, df={len(idx)}, p={float(jt.pvalue):.4f}")
print(" -> small p = groups improve at DIFFERENT rates; large p = parallel learning.")
def report_anchor_only(df):
"""A2-vs-B2 ONLY: refit on just the two anchors so nothing else influences
the shared day terms / dispersion. B2 is the reference, so the single group
term is the A2-vs-B2 comparison."""
print("\n" + "=" * 78)
print("ANCHORS ONLY — Box-A2 vs Box-B2 (two-group models)")
print("=" * 78)
d = df[df["group"].isin([ANCHOR_LOW, ANCHOR_HIGH])].copy()
d["day_c"] = d["day"] - d["day"].mean()
d["day_c2"] = d["day_c"] ** 2
d["group"] = pd.Categorical(d["group"], categories=[ANCHOR_HIGH, ANCHOR_LOW])
n_sub = d.groupby("group", observed=True)["subject"].nunique().to_dict()
print(f"subjects: Box-B2={n_sub.get(ANCHOR_HIGH)}, Box-A2={n_sub.get(ANCHOR_LOW)}"
f" sessions: {len(d)}")
print(f"formula: success ~ C(group, Treatment('{REF}')) + day_c + day_c2")
for label, res in [("count/level (Poisson GEE)", fit_count_level(d)),
("rate/level (Binomial cluster-robust)", fit_rate_level(d))]:
c = contrast(res, gterm(ANCHOR_LOW))
p1 = one_sided_p_below(c)
tag = "SUPPORTED" if (c["coef"] < 0 and p1 < 0.05) else "not supported"
print(f" [{label}] A2/B2 = {c['eff']:.2f} [{c['lo']:.2f}, {c['hi']:.2f}] "
f"=> B2 = {1.0 / c['eff']:.2f}x A2, one-sided p={p1:.4f} -> H2 {tag}")
def report_mixed(df):
"""Subject random-intercept Poisson mixed model (sensitivity vs GEE)."""
print("\n" + "=" * 78)
print("SENSITIVITY — Poisson MIXED model, per-subject random intercept (MAP/Laplace)")
print("=" * 78)
try:
res = fit_mixed_count(df)
except Exception as exc: # VB can be finicky; degrade gracefully
print(f" (mixed model skipped: {type(exc).__name__}: {exc})")
return
print("Group effect vs Box-B2 (IRR; each subject given its own baseline):")
h = f"{'group':20s} {'IRR':>7s} {'~95% CI':>16s} {'mean':>8s} {'SD':>7s} {'~p':>9s}"
print(h)
print("-" * len(h))
for g in OTHERS:
c = mixed_contrast(res, gterm(g))
ci = f"[{c['lo']:.2f}, {c['hi']:.2f}]"
print(f"{g:20s} {c['eff']:7.3f} {ci:>16s} {c['coef']:8.3f} {c['se']:7.3f} {c['p']:9.4f}")
print(f" subject random-intercept SD = {float(np.exp(res.vcp_mean[0])):.3f} "
"(log scale); Laplace SEs, treat p-values as approximate.")
print(" Compare directions/magnitudes with the GEE table (they should agree).")
print(" Single-subject groups (Box-A, Right-Electrode) are partly confounded")
print(" with their own random intercept here, so their effects are shrunk.")
def plot_curves(df, plot_path):
fig, ax = plt.subplots(1, 2, figsize=(12, 4.8))
for g in MAIN:
s = df[df["group"] == g]
d = s.groupby("day").apply(
lambda x: pd.Series({"succ": x["success"].mean(),
"rate": x["success"].sum() / x["total"].sum()}),
include_groups=False)
ax[0].plot(d.index, d["succ"], marker="o", ms=3, label=g)
ax[1].plot(d.index, d["rate"], marker="o", ms=3, label=g)
ax[0].set(xlabel="# Days Reach (training day)", ylabel="mean successful reaches",
title="(A) Success count learning curves")
ax[1].set(xlabel="# Days Reach (training day)", ylabel="success rate (success/attempts)",
title="(B) Success-rate learning curves")
for a in ax:
a.legend(fontsize=8)
a.grid(alpha=0.3)
fig.tight_layout()
fig.savefig(plot_path, dpi=120)
print(f"\nSaved learning-curve plot -> {plot_path}")
def classify_one(res, unknown):
"""Compare an unknown group against BOTH anchors; return a label + detail."""
vlo = diff_contrast(res, unknown, ANCHOR_LOW) # unknown vs Box-A2
vhi = diff_contrast(res, unknown, ANCHOR_HIGH) # unknown vs Box-B2
like_lo = vlo["p"] >= 0.05 # indistinguishable from Box-A2
like_hi = vhi["p"] >= 0.05 # indistinguishable from Box-B2
if like_hi and not like_lo:
verdict = "B2-like (differs from A2, matches B2)"
elif like_lo and not like_hi:
verdict = "A2-like (differs from B2, matches A2)"
elif like_lo and like_hi:
# tie: point toward whichever ratio is closer to 1 on the log scale
nearer = ANCHOR_HIGH if abs(vhi["coef"]) < abs(vlo["coef"]) else ANCHOR_LOW
verdict = f"AMBIGUOUS (matches both; numerically nearer {nearer})"
else:
verdict = "UNLIKE BOTH (differs from A2 and B2)"
return vlo, vhi, verdict
def verdicts(count, rate):
print("\n" + "=" * 78)
print("VERDICTS")
print("=" * 78)
print("\nAnchor check — H2: Box-B2 BETTER than Box-A2 (the two anchors must differ)")
for label, res in [("count/level", count), ("rate/level ", rate)]:
c = contrast(res, gterm(ANCHOR_LOW))
p1 = one_sided_p_below(c)
tag = "SUPPORTED" if (c["coef"] < 0 and p1 < 0.05) else "not supported"
print(f" [{label}] Box-B2 = {1.0 / c['eff']:.2f}x Box-A2 "
f"(one-sided p={p1:.4f}) -> {tag}")
if not UNKNOWNS:
print("\n(No unknown groups — they were merged into the anchors; "
"only the H2 anchor contrast applies.)")
return
print("\nClassification — is each UNKNOWN condition A2-like or B2-like?")
print("(ratio >1 = above that anchor; p = differs from that anchor)")
for u in UNKNOWNS:
print(f"\n {u}:")
for label, res in [("count/level", count), ("rate/level ", rate)]:
vlo, vhi, verdict = classify_one(res, u)
print(f" [{label}] vs Box-A2: {vlo['eff']:.2f}x "
f"[{vlo['lo']:.2f},{vlo['hi']:.2f}] p={vlo['p']:.3f} "
f"vs Box-B2: {vhi['eff']:.2f}x [{vhi['lo']:.2f},{vhi['hi']:.2f}] "
f"p={vhi['p']:.3f}")
print(f" -> {verdict}")
print("\n NOTE: each unknown has ONLY 1 subject. 'Matches' means 'not statistically")
print(" distinguishable' — weak evidence at n=1, not proof of equivalence.")
def main():
ap = argparse.ArgumentParser(description="tDCS GLM hypothesis tests")
ap.add_argument("--max-day", type=int, default=None,
help="restrict analysis to training days 0..MAX_DAY (default: all)")
ap.add_argument("--merge", action="store_true",
help="preset: Electrode-Box-A->Box-A2 and Right-Electrode->Box-B2")
ap.add_argument("--assign", type=str, default=None,
help="custom merges 'UNKNOWN=TARGET,...', e.g. "
"'Electrode-Box-A=Electrode-Box-B2,Right-Electrode=Electrode-Box-B2'")
args = ap.parse_args()
mapping = {}
if args.merge:
mapping.update(MERGE_MAP)
if args.assign:
for pair in args.assign.split(","):
k, v = pair.split("=")
mapping[k.strip()] = v.strip()
df = load(args.max_day, mapping)
global MAIN, OTHERS, UNKNOWNS
present = set(df["group"])
MAIN = [g for g in ALL_GROUPS if g in present]
OTHERS = [g for g in MAIN if g != REF]
UNKNOWNS = [g for g in CANON_UNKNOWNS if g in present]
window = f"days 0-{args.max_day}" if args.max_day is not None else "all training days (0+)"
tag = f"_d0-{args.max_day}" if args.max_day is not None else ""
tag += "_merged" if mapping else ""
plot_path = os.path.join(HERE, f"tdcs_learning_curves{tag}.png")
mode = ("assign " + ", ".join(f"{k.replace('Electrode-','')}->{v.replace('Electrode-','')}"
for k, v in mapping.items())) if mapping \
else "5 groups (anchors + unknowns)"
print(f"ANALYSIS WINDOW: {window} | MODE: {mode} | observations: {len(df)}")
describe(df)
count = fit_count_level(df)
rate = fit_rate_level(df)
learn = fit_learning_rate(df)
report_level("(A) LEVEL / COUNT — Poisson GEE on successful reaches",
count, "incidence-rate ratio")
report_level("(B) LEVEL / RATE — Binomial GLM on success/attempts (cluster-robust)",
rate, "odds ratio")
report_learning(learn)
report_anchor_only(df)
report_mixed(df)
verdicts(count, rate)
plot_curves(df, plot_path)
print("\nDone. See the module docstring for modeling choices and caveats.")
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

+194
View File
@@ -0,0 +1,194 @@
subject,group,day,success,total
Vu-vuong,Naive,0,18,61
Vu-vuong,Naive,1,35,91
Vu-vuong,Naive,2,61,127
Vu-vuong,Naive,3,34,146
Vu-vuong,Naive,4,61,141
Vu-vuong,Naive,5,37,151
Vu-vuong,Naive,6,49,131
Vu-vuong,Naive,7,65,149
Vu-vuong,Naive,8,67,141
Vu-vuong,Naive,9,73,140
Vu-vuong,Naive,10,72,131
Vu-vuong,Naive,11,89,158
Vu-vuong,Naive,12,97,154
Vu-vuong,Naive,13,93,145
Vu-vuong,Naive,14,75,147
Vu-vuong,Naive,15,68,142
Vu-vuong,Naive,16,96,162
Vu-vuong,Naive,17,68,144
Vu-vuong,Naive,18,81,134
Vu-vuong,Naive,19,66,144
Vu-vuong,Naive,20,84,127
Vu-vuong,Naive,21,66,133
Khoai-tay-1,Electrode-Box-A,-2,1,18
Khoai-tay-1,Electrode-Box-A,-1,3,24
Khoai-tay-1,Electrode-Box-A,0,14,62
Khoai-tay-1,Electrode-Box-A,1,22,83
Khoai-tay-1,Electrode-Box-A,2,6,79
Khoai-tay-1,Electrode-Box-A,3,28,97
Khoai-tay-1,Electrode-Box-A,4,60,134
Khoai-tay-1,Electrode-Box-A,5,75,138
Khoai-tay-1,Electrode-Box-A,6,81,137
Khoai-tay-1,Electrode-Box-A,7,78,147
Khoai-tay-1,Electrode-Box-A,8,91,132
Khoai-tay-1,Electrode-Box-A,9,99,146
Khoai-tay-1,Electrode-Box-A,10,94,143
Khoai-tay-1,Electrode-Box-A,11,110,156
Khoai-tay-1,Electrode-Box-A,12,105,143
Khoai-tay-1,Electrode-Box-A,13,106,153
Khoai-tay-1,Electrode-Box-A,14,91,152
Banh-mi-2,Electrode-Box-A2,-1,2,42
Banh-mi-2,Electrode-Box-A2,0,25,97
Banh-mi-2,Electrode-Box-A2,1,22,101
Banh-mi-2,Electrode-Box-A2,2,22,119
Banh-mi-2,Electrode-Box-A2,3,29,118
Banh-mi-2,Electrode-Box-A2,4,27,136
Banh-mi-2,Electrode-Box-A2,5,43,146
Banh-mi-2,Electrode-Box-A2,6,74,146
Banh-mi-2,Electrode-Box-A2,7,70,148
Banh-mi-2,Electrode-Box-A2,8,65,130
Banh-mi-2,Electrode-Box-A2,9,79,151
Banh-mi-2,Electrode-Box-A2,10,93,152
Egg-tart-2,Electrode-Box-A2,0,9,32
Egg-tart-2,Electrode-Box-A2,1,2,38
Egg-tart-2,Electrode-Box-A2,2,31,93
Egg-tart-2,Electrode-Box-A2,3,44,101
Egg-tart-2,Electrode-Box-A2,4,54,131
Egg-tart-2,Electrode-Box-A2,5,84,139
Egg-tart-2,Electrode-Box-A2,6,85,145
Egg-tart-2,Electrode-Box-A2,7,79,143
Egg-tart-2,Electrode-Box-A2,8,76,131
Egg-tart-2,Electrode-Box-A2,9,88,149
Egg-tart-2,Electrode-Box-A2,10,81,151
Egg-tart-2,Electrode-Box-A2,11,78,152
Egg-tart-2,Electrode-Box-A2,12,96,155
Egg-tart-2,Electrode-Box-A2,13,84,155
Root-beer-2,Electrode-Box-A2,0,22,74
Root-beer-2,Electrode-Box-A2,1,31,87
Root-beer-2,Electrode-Box-A2,2,49,134
Root-beer-2,Electrode-Box-A2,3,31,89
Root-beer-2,Electrode-Box-A2,4,60,140
Root-beer-2,Electrode-Box-A2,5,84,147
Banh-mi-1,Electrode-Box-B2,0,13,84
Banh-mi-1,Electrode-Box-B2,1,35,86
Banh-mi-1,Electrode-Box-B2,2,47,110
Banh-mi-1,Electrode-Box-B2,3,65,140
Banh-mi-1,Electrode-Box-B2,4,70,127
Banh-mi-1,Electrode-Box-B2,5,102,142
Banh-mi-1,Electrode-Box-B2,6,90,131
Banh-mi-1,Electrode-Box-B2,7,109,148
Banh-mi-1,Electrode-Box-B2,8,104,137
Banh-mi-1,Electrode-Box-B2,9,119,150
Banh-mi-1,Electrode-Box-B2,10,121,158
Banh-mi-1,Electrode-Box-B2,11,121,148
Banh-mi-1,Electrode-Box-B2,12,120,149
Banh-mi-1,Electrode-Box-B2,13,135,154
Egg-tart-1,Electrode-Box-B2,0,7,56
Egg-tart-1,Electrode-Box-B2,1,16,78
Egg-tart-1,Electrode-Box-B2,2,23,103
Egg-tart-1,Electrode-Box-B2,3,63,120
Egg-tart-1,Electrode-Box-B2,4,69,132
Egg-tart-1,Electrode-Box-B2,5,83,136
Egg-tart-1,Electrode-Box-B2,6,71,142
Egg-tart-1,Electrode-Box-B2,7,79,138
Egg-tart-1,Electrode-Box-B2,8,98,142
Egg-tart-1,Electrode-Box-B2,9,89,139
Egg-tart-1,Electrode-Box-B2,10,96,143
Egg-tart-1,Electrode-Box-B2,11,96,148
Egg-tart-1,Electrode-Box-B2,12,101,156
Egg-tart-1,Electrode-Box-B2,13,103,152
Egg-tart-1,Electrode-Box-B2,14,97,152
Root-beer-1,Electrode-Box-B2,0,11,85
Root-beer-1,Electrode-Box-B2,1,18,76
Root-beer-1,Electrode-Box-B2,2,40,105
Root-beer-1,Electrode-Box-B2,3,55,134
Root-beer-1,Electrode-Box-B2,4,75,136
Root-beer-1,Electrode-Box-B2,5,64,133
Root-beer-1,Electrode-Box-B2,6,104,139
Root-beer-1,Electrode-Box-B2,7,98,148
Root-beer-1,Electrode-Box-B2,8,81,145
Root-beer-1,Electrode-Box-B2,9,89,156
Root-beer-1,Electrode-Box-B2,10,105,158
Khoai-lang-2,Naive,0,0,0
Khoai-lang-2,Naive,1,0,0
Khoai-lang-2,Naive,2,10,47
Khoai-lang-2,Naive,3,11,52
Khoai-lang-2,Naive,4,9,56
Khoai-lang-2,Naive,5,34,95
Khoai-lang-2,Naive,6,21,72
Khoai-lang-2,Naive,7,23,99
Khoai-lang-2,Naive,8,64,136
Khoai-lang-2,Naive,9,75,131
Khoai-lang-2,Naive,10,63,134
Khoai-lang-2,Naive,11,59,139
Khoai-lang-2,Naive,12,51,129
Khoai-lang-2,Naive,13,73,143
Khoai-lang-2,Naive,14,82,136
Khoai-lang-2,Naive,15,70,145
Khoai-lang-2,Naive,16,76,135
Khoai-lang-2,Naive,17,76,150
Khoai-lang-2,Naive,18,63,122
Khoai-lang-2,Naive,19,48,116
Khoai-lang-2,Naive,20,65,134
Khoai-lang-2,Naive,21,75,131
Khoai-lang-2,Naive,22,98,146
Khoai-lang-2,Naive,23,88,139
Khoai-lang-2,Naive,24,94,148
Khoai-lang-2,Naive,25,56,102
Khoai-lang-2,Naive,26,75,143
Khoai-tay-2,Naive,1,21,68
Khoai-tay-2,Naive,2,6,79
Khoai-tay-2,Naive,3,0,83
Khoai-tay-2,Naive,4,28,87
Khoai-tay-2,Naive,5,31,125
Khoai-tay-2,Naive,6,62,144
Khoai-tay-2,Naive,7,87,141
Khoai-tay-2,Naive,8,105,152
Khoai-tay-2,Naive,9,75,148
Khoai-tay-2,Naive,10,101,149
Khoai-tay-2,Naive,11,102,154
Khoai-tay-2,Naive,12,95,144
Khoai-tay-2,Naive,13,77,149
Khoai-tay-2,Naive,14,91,153
OM-2,Naive,-2,0,0
OM-2,Naive,-1,5,18
OM-2,Naive,0,1,7
OM-2,Naive,1,11,33
OM-2,Naive,2,19,62
OM-2,Naive,3,29,117
OM-2,Naive,4,73,126
OM-2,Naive,5,53,135
OM-2,Naive,6,73,138
OM-2,Naive,7,80,131
OM-2,Naive,8,91,141
OM-2,Naive,9,90,135
OM-2,Naive,10,95,142
OM-2,Naive,11,60,133
OM-2,Naive,12,58,142
Khoai-lang-1,Right-Electrode,-4,0,0
Khoai-lang-1,Right-Electrode,-3,2,7
Khoai-lang-1,Right-Electrode,-1,0,0
Khoai-lang-1,Right-Electrode,0,3,69
Khoai-lang-1,Right-Electrode,1,18,97
Khoai-lang-1,Right-Electrode,2,27,84
Khoai-lang-1,Right-Electrode,3,46,121
Khoai-lang-1,Right-Electrode,4,65,143
Khoai-lang-1,Right-Electrode,5,76,141
Khoai-lang-1,Right-Electrode,6,79,146
Khoai-lang-1,Right-Electrode,7,81,153
Khoai-lang-1,Right-Electrode,8,98,144
Khoai-lang-1,Right-Electrode,9,101,149
Khoai-lang-1,Right-Electrode,10,103,150
Khoai-lang-1,Right-Electrode,11,113,154
Khoai-lang-1,Right-Electrode,12,117,148
Khoai-lang-1,Right-Electrode,13,114,146
Khoai-lang-1,Right-Electrode,14,119,132
Khoai-lang-1,Right-Electrode,15,111,156
Khoai-lang-1,Right-Electrode,16,74,156
Khoai-lang-1,Right-Electrode,17,109,139
Khoai-lang-1,Right-Electrode,18,88,138
Khoai-lang-1,Right-Electrode,19,98,140
Khoai-lang-1,Right-Electrode,20,118,153
Khoai-lang-1,Right-Electrode,21,109,146
Khoai-lang-1,Right-Electrode,22,106,141
1 subject group day success total
2 Vu-vuong Naive 0 18 61
3 Vu-vuong Naive 1 35 91
4 Vu-vuong Naive 2 61 127
5 Vu-vuong Naive 3 34 146
6 Vu-vuong Naive 4 61 141
7 Vu-vuong Naive 5 37 151
8 Vu-vuong Naive 6 49 131
9 Vu-vuong Naive 7 65 149
10 Vu-vuong Naive 8 67 141
11 Vu-vuong Naive 9 73 140
12 Vu-vuong Naive 10 72 131
13 Vu-vuong Naive 11 89 158
14 Vu-vuong Naive 12 97 154
15 Vu-vuong Naive 13 93 145
16 Vu-vuong Naive 14 75 147
17 Vu-vuong Naive 15 68 142
18 Vu-vuong Naive 16 96 162
19 Vu-vuong Naive 17 68 144
20 Vu-vuong Naive 18 81 134
21 Vu-vuong Naive 19 66 144
22 Vu-vuong Naive 20 84 127
23 Vu-vuong Naive 21 66 133
24 Khoai-tay-1 Electrode-Box-A -2 1 18
25 Khoai-tay-1 Electrode-Box-A -1 3 24
26 Khoai-tay-1 Electrode-Box-A 0 14 62
27 Khoai-tay-1 Electrode-Box-A 1 22 83
28 Khoai-tay-1 Electrode-Box-A 2 6 79
29 Khoai-tay-1 Electrode-Box-A 3 28 97
30 Khoai-tay-1 Electrode-Box-A 4 60 134
31 Khoai-tay-1 Electrode-Box-A 5 75 138
32 Khoai-tay-1 Electrode-Box-A 6 81 137
33 Khoai-tay-1 Electrode-Box-A 7 78 147
34 Khoai-tay-1 Electrode-Box-A 8 91 132
35 Khoai-tay-1 Electrode-Box-A 9 99 146
36 Khoai-tay-1 Electrode-Box-A 10 94 143
37 Khoai-tay-1 Electrode-Box-A 11 110 156
38 Khoai-tay-1 Electrode-Box-A 12 105 143
39 Khoai-tay-1 Electrode-Box-A 13 106 153
40 Khoai-tay-1 Electrode-Box-A 14 91 152
41 Banh-mi-2 Electrode-Box-A2 -1 2 42
42 Banh-mi-2 Electrode-Box-A2 0 25 97
43 Banh-mi-2 Electrode-Box-A2 1 22 101
44 Banh-mi-2 Electrode-Box-A2 2 22 119
45 Banh-mi-2 Electrode-Box-A2 3 29 118
46 Banh-mi-2 Electrode-Box-A2 4 27 136
47 Banh-mi-2 Electrode-Box-A2 5 43 146
48 Banh-mi-2 Electrode-Box-A2 6 74 146
49 Banh-mi-2 Electrode-Box-A2 7 70 148
50 Banh-mi-2 Electrode-Box-A2 8 65 130
51 Banh-mi-2 Electrode-Box-A2 9 79 151
52 Banh-mi-2 Electrode-Box-A2 10 93 152
53 Egg-tart-2 Electrode-Box-A2 0 9 32
54 Egg-tart-2 Electrode-Box-A2 1 2 38
55 Egg-tart-2 Electrode-Box-A2 2 31 93
56 Egg-tart-2 Electrode-Box-A2 3 44 101
57 Egg-tart-2 Electrode-Box-A2 4 54 131
58 Egg-tart-2 Electrode-Box-A2 5 84 139
59 Egg-tart-2 Electrode-Box-A2 6 85 145
60 Egg-tart-2 Electrode-Box-A2 7 79 143
61 Egg-tart-2 Electrode-Box-A2 8 76 131
62 Egg-tart-2 Electrode-Box-A2 9 88 149
63 Egg-tart-2 Electrode-Box-A2 10 81 151
64 Egg-tart-2 Electrode-Box-A2 11 78 152
65 Egg-tart-2 Electrode-Box-A2 12 96 155
66 Egg-tart-2 Electrode-Box-A2 13 84 155
67 Root-beer-2 Electrode-Box-A2 0 22 74
68 Root-beer-2 Electrode-Box-A2 1 31 87
69 Root-beer-2 Electrode-Box-A2 2 49 134
70 Root-beer-2 Electrode-Box-A2 3 31 89
71 Root-beer-2 Electrode-Box-A2 4 60 140
72 Root-beer-2 Electrode-Box-A2 5 84 147
73 Banh-mi-1 Electrode-Box-B2 0 13 84
74 Banh-mi-1 Electrode-Box-B2 1 35 86
75 Banh-mi-1 Electrode-Box-B2 2 47 110
76 Banh-mi-1 Electrode-Box-B2 3 65 140
77 Banh-mi-1 Electrode-Box-B2 4 70 127
78 Banh-mi-1 Electrode-Box-B2 5 102 142
79 Banh-mi-1 Electrode-Box-B2 6 90 131
80 Banh-mi-1 Electrode-Box-B2 7 109 148
81 Banh-mi-1 Electrode-Box-B2 8 104 137
82 Banh-mi-1 Electrode-Box-B2 9 119 150
83 Banh-mi-1 Electrode-Box-B2 10 121 158
84 Banh-mi-1 Electrode-Box-B2 11 121 148
85 Banh-mi-1 Electrode-Box-B2 12 120 149
86 Banh-mi-1 Electrode-Box-B2 13 135 154
87 Egg-tart-1 Electrode-Box-B2 0 7 56
88 Egg-tart-1 Electrode-Box-B2 1 16 78
89 Egg-tart-1 Electrode-Box-B2 2 23 103
90 Egg-tart-1 Electrode-Box-B2 3 63 120
91 Egg-tart-1 Electrode-Box-B2 4 69 132
92 Egg-tart-1 Electrode-Box-B2 5 83 136
93 Egg-tart-1 Electrode-Box-B2 6 71 142
94 Egg-tart-1 Electrode-Box-B2 7 79 138
95 Egg-tart-1 Electrode-Box-B2 8 98 142
96 Egg-tart-1 Electrode-Box-B2 9 89 139
97 Egg-tart-1 Electrode-Box-B2 10 96 143
98 Egg-tart-1 Electrode-Box-B2 11 96 148
99 Egg-tart-1 Electrode-Box-B2 12 101 156
100 Egg-tart-1 Electrode-Box-B2 13 103 152
101 Egg-tart-1 Electrode-Box-B2 14 97 152
102 Root-beer-1 Electrode-Box-B2 0 11 85
103 Root-beer-1 Electrode-Box-B2 1 18 76
104 Root-beer-1 Electrode-Box-B2 2 40 105
105 Root-beer-1 Electrode-Box-B2 3 55 134
106 Root-beer-1 Electrode-Box-B2 4 75 136
107 Root-beer-1 Electrode-Box-B2 5 64 133
108 Root-beer-1 Electrode-Box-B2 6 104 139
109 Root-beer-1 Electrode-Box-B2 7 98 148
110 Root-beer-1 Electrode-Box-B2 8 81 145
111 Root-beer-1 Electrode-Box-B2 9 89 156
112 Root-beer-1 Electrode-Box-B2 10 105 158
113 Khoai-lang-2 Naive 0 0 0
114 Khoai-lang-2 Naive 1 0 0
115 Khoai-lang-2 Naive 2 10 47
116 Khoai-lang-2 Naive 3 11 52
117 Khoai-lang-2 Naive 4 9 56
118 Khoai-lang-2 Naive 5 34 95
119 Khoai-lang-2 Naive 6 21 72
120 Khoai-lang-2 Naive 7 23 99
121 Khoai-lang-2 Naive 8 64 136
122 Khoai-lang-2 Naive 9 75 131
123 Khoai-lang-2 Naive 10 63 134
124 Khoai-lang-2 Naive 11 59 139
125 Khoai-lang-2 Naive 12 51 129
126 Khoai-lang-2 Naive 13 73 143
127 Khoai-lang-2 Naive 14 82 136
128 Khoai-lang-2 Naive 15 70 145
129 Khoai-lang-2 Naive 16 76 135
130 Khoai-lang-2 Naive 17 76 150
131 Khoai-lang-2 Naive 18 63 122
132 Khoai-lang-2 Naive 19 48 116
133 Khoai-lang-2 Naive 20 65 134
134 Khoai-lang-2 Naive 21 75 131
135 Khoai-lang-2 Naive 22 98 146
136 Khoai-lang-2 Naive 23 88 139
137 Khoai-lang-2 Naive 24 94 148
138 Khoai-lang-2 Naive 25 56 102
139 Khoai-lang-2 Naive 26 75 143
140 Khoai-tay-2 Naive 1 21 68
141 Khoai-tay-2 Naive 2 6 79
142 Khoai-tay-2 Naive 3 0 83
143 Khoai-tay-2 Naive 4 28 87
144 Khoai-tay-2 Naive 5 31 125
145 Khoai-tay-2 Naive 6 62 144
146 Khoai-tay-2 Naive 7 87 141
147 Khoai-tay-2 Naive 8 105 152
148 Khoai-tay-2 Naive 9 75 148
149 Khoai-tay-2 Naive 10 101 149
150 Khoai-tay-2 Naive 11 102 154
151 Khoai-tay-2 Naive 12 95 144
152 Khoai-tay-2 Naive 13 77 149
153 Khoai-tay-2 Naive 14 91 153
154 OM-2 Naive -2 0 0
155 OM-2 Naive -1 5 18
156 OM-2 Naive 0 1 7
157 OM-2 Naive 1 11 33
158 OM-2 Naive 2 19 62
159 OM-2 Naive 3 29 117
160 OM-2 Naive 4 73 126
161 OM-2 Naive 5 53 135
162 OM-2 Naive 6 73 138
163 OM-2 Naive 7 80 131
164 OM-2 Naive 8 91 141
165 OM-2 Naive 9 90 135
166 OM-2 Naive 10 95 142
167 OM-2 Naive 11 60 133
168 OM-2 Naive 12 58 142
169 Khoai-lang-1 Right-Electrode -4 0 0
170 Khoai-lang-1 Right-Electrode -3 2 7
171 Khoai-lang-1 Right-Electrode -1 0 0
172 Khoai-lang-1 Right-Electrode 0 3 69
173 Khoai-lang-1 Right-Electrode 1 18 97
174 Khoai-lang-1 Right-Electrode 2 27 84
175 Khoai-lang-1 Right-Electrode 3 46 121
176 Khoai-lang-1 Right-Electrode 4 65 143
177 Khoai-lang-1 Right-Electrode 5 76 141
178 Khoai-lang-1 Right-Electrode 6 79 146
179 Khoai-lang-1 Right-Electrode 7 81 153
180 Khoai-lang-1 Right-Electrode 8 98 144
181 Khoai-lang-1 Right-Electrode 9 101 149
182 Khoai-lang-1 Right-Electrode 10 103 150
183 Khoai-lang-1 Right-Electrode 11 113 154
184 Khoai-lang-1 Right-Electrode 12 117 148
185 Khoai-lang-1 Right-Electrode 13 114 146
186 Khoai-lang-1 Right-Electrode 14 119 132
187 Khoai-lang-1 Right-Electrode 15 111 156
188 Khoai-lang-1 Right-Electrode 16 74 156
189 Khoai-lang-1 Right-Electrode 17 109 139
190 Khoai-lang-1 Right-Electrode 18 88 138
191 Khoai-lang-1 Right-Electrode 19 98 140
192 Khoai-lang-1 Right-Electrode 20 118 153
193 Khoai-lang-1 Right-Electrode 21 109 146
194 Khoai-lang-1 Right-Electrode 22 106 141
@@ -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;
+43
View File
@@ -13,6 +13,8 @@ model Experiment {
title String title String
created_at DateTime @default(now()) created_at DateTime @default(now())
template Json @default("[]") template Json @default("[]")
subject_info_template Json @default("[]")
plot_config Json?
animals Animal[] animals Animal[]
@@map("experiments") @@map("experiments")
@@ -23,6 +25,8 @@ model Animal {
experiment_id String experiment_id String
animal_id_string String animal_id_string String
animal_name String animal_name String
subject_info Json?
csv_analysis_config Json?
experiment Experiment @relation(fields: [experiment_id], references: [id], onDelete: Cascade) experiment Experiment @relation(fields: [experiment_id], references: [id], onDelete: Cascade)
daily_statuses DailyStatus[] daily_statuses DailyStatus[]
@@ -38,11 +42,50 @@ model DailyStatus {
treatment String? treatment String?
notes String? notes String?
custom_fields Json? custom_fields Json?
analysis_summary Json?
animal Animal @relation(fields: [animal_id], references: [id], onDelete: Cascade) animal Animal @relation(fields: [animal_id], references: [id], onDelete: Cascade)
analyses DailyAnalysis[]
session_files SessionFile[]
@@map("daily_statuses") @@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 { model AuditLog {
id String @id @default(uuid()) id String @id @default(uuid())
table_name String table_name String
+15
View File
@@ -9,6 +9,8 @@ const experimentsRouter = require('./routes/experiments');
const animalsRouter = require('./routes/animals'); const animalsRouter = require('./routes/animals');
const dailyStatusesRouter = require('./routes/dailyStatuses'); const dailyStatusesRouter = require('./routes/dailyStatuses');
const auditLogsRouter = require('./routes/auditLogs'); const auditLogsRouter = require('./routes/auditLogs');
const analysesRouter = require('./routes/analyses');
const sessionFilesRouter = require('./routes/sessionFiles');
const app = express(); const app = express();
@@ -43,6 +45,17 @@ app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() }); 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 // Apply rate limiter to all API routes
app.use('/api', apiLimiter); app.use('/api', apiLimiter);
@@ -50,6 +63,8 @@ app.use('/api/experiments', experimentsRouter);
app.use('/api/animals', animalsRouter); app.use('/api/animals', animalsRouter);
app.use('/api/daily-statuses', dailyStatusesRouter); app.use('/api/daily-statuses', dailyStatusesRouter);
app.use('/api/audit-logs', auditLogsRouter); app.use('/api/audit-logs', auditLogsRouter);
app.use('/api', analysesRouter);
app.use('/api', sessionFilesRouter);
app.use((req, res) => { app.use((req, res) => {
res.status(404).json({ error: `Route ${req.method} ${req.path} not found` }); res.status(404).json({ error: `Route ${req.method} ${req.path} not found` });
+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 };
+16 -14
View File
@@ -1,25 +1,27 @@
/** /**
* The default daily-record template applied to every new experiment * The default daily-record template applied to every new experiment.
* (and returned as a fallback when an experiment's template is empty).
* *
* `builtin: true` — value lives in the named column of daily_statuses * `fieldId` — stable UUID; used as the storage key in custom_fields.
* `builtin: false` — value lives in daily_statuses.custom_fields JSON * Builtin fields have fixed canonical UUIDs so they are
* `active: false` — field is hidden (not deleted; data preserved) * 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 = [ const DEFAULT_TEMPLATE = [
{ key: 'experiment_description', label: 'Experiment Description', type: 'textarea', builtin: true, active: true }, { fieldId: '00000000-0000-0000-0000-000000000001', key: 'experiment_description', label: 'Experiment Description', type: 'textarea', builtin: true, active: true, tags: [] },
{ key: 'vitals', label: 'Vitals', type: 'text', builtin: true, active: true }, { fieldId: '00000000-0000-0000-0000-000000000002', key: 'vitals', label: 'Vitals', type: 'text', builtin: true, active: true, tags: [] },
{ key: 'treatment', label: 'Treatment', type: 'text', builtin: true, active: true }, { fieldId: '00000000-0000-0000-0000-000000000003', key: 'treatment', label: 'Treatment', type: 'text', builtin: true, active: true, tags: [] },
{ key: 'notes', label: 'Notes', type: 'textarea', builtin: true, active: true }, { 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;
* Resolve the effective template for an experiment.
* If the stored template is empty (new experiment), return the default.
*/
function resolveTemplate(stored) { function resolveTemplate(stored) {
if (!Array.isArray(stored) || stored.length === 0) return DEFAULT_TEMPLATE; if (!Array.isArray(stored) || stored.length === 0) return DEFAULT_TEMPLATE;
return stored; return stored;
} }
module.exports = { DEFAULT_TEMPLATE, resolveTemplate }; module.exports = { DEFAULT_TEMPLATE, resolveTemplate, UUID_RE };
+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;
+24 -1
View File
@@ -3,6 +3,7 @@ const { body, query } = require('express-validator');
const prisma = require('../lib/prisma'); const prisma = require('../lib/prisma');
const auditMiddleware = require('../middleware/auditMiddleware'); const auditMiddleware = require('../middleware/auditMiddleware');
const { validateRequest } = require('../middleware/errorHandler'); const { validateRequest } = require('../middleware/errorHandler');
const { validateCsvConfig } = require('../lib/csvConfigValidation');
const animalValidation = [ const animalValidation = [
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('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'),
@@ -13,6 +14,7 @@ const animalValidation = [
const animalUpdateValidation = [ const animalUpdateValidation = [
body('animal_id_string').optional().trim().notEmpty().withMessage('animal_id_string cannot be empty'), 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('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 // GET /api/animals?experimentId=<uuid> — list for experiment
@@ -80,10 +82,11 @@ router.put(
validateRequest, validateRequest,
async (req, res, next) => { async (req, res, next) => {
try { try {
const { animal_id_string, animal_name } = req.body; const { animal_id_string, animal_name, subject_info } = req.body;
const data = {}; const data = {};
if (animal_id_string !== undefined) data.animal_id_string = animal_id_string; if (animal_id_string !== undefined) data.animal_id_string = animal_id_string;
if (animal_name !== undefined) data.animal_name = animal_name; if (animal_name !== undefined) data.animal_name = animal_name;
if (subject_info !== undefined) data.subject_info = subject_info;
const animal = await prisma.animal.update({ const animal = await prisma.animal.update({
where: { id: req.params.id }, 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; module.exports = router;
+62 -10
View File
@@ -1,26 +1,37 @@
const router = require('express').Router(); const router = require('express').Router();
const { body, query } = require('express-validator'); const { body, query } = require('express-validator');
const prisma = require('../lib/prisma'); const prisma = require('../lib/prisma');
const cache = require('../lib/cache');
const auditMiddleware = require('../middleware/auditMiddleware'); const auditMiddleware = require('../middleware/auditMiddleware');
const { validateRequest } = require('../middleware/errorHandler'); 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 = [ const statusValidation = [
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('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('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('experiment_description').optional({ nullable: true }).isString(),
body('vitals').optional().isString(), body('vitals').optional({ nullable: true }).isString(),
body('treatment').optional().isString(), body('treatment').optional({ nullable: true }).isString(),
body('notes').optional().isString(), body('notes').optional({ nullable: true }).isString(),
body('custom_fields').optional().isObject().withMessage('custom_fields must be an object'), body('custom_fields').optional({ nullable: true }).isObject().withMessage('custom_fields must be an object'),
]; ];
const statusUpdateValidation = [ const statusUpdateValidation = [
body('date').optional().isISO8601().withMessage('date must be a valid ISO 8601 date'), body('date').optional().isISO8601().withMessage('date must be a valid ISO 8601 date'),
body('experiment_description').optional().isString(), body('experiment_description').optional({ nullable: true }).isString(),
body('vitals').optional().isString(), body('vitals').optional({ nullable: true }).isString(),
body('treatment').optional().isString(), body('treatment').optional({ nullable: true }).isString(),
body('notes').optional().isString(), body('notes').optional({ nullable: true }).isString(),
body('custom_fields').optional().isObject().withMessage('custom_fields must be an object'), body('custom_fields').optional({ nullable: true }).isObject().withMessage('custom_fields must be an object'),
]; ];
// GET /api/daily-statuses?animalId=<uuid> // GET /api/daily-statuses?animalId=<uuid>
@@ -71,6 +82,9 @@ router.post('/', statusValidation, validateRequest, async (req, res, next) => {
await prisma.auditLog.create({ 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); res.status(201).json(status);
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
@@ -93,18 +107,56 @@ router.put(
if (custom_fields !== undefined) data.custom_fields = custom_fields; 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); res.json(status);
} catch (err) { next(err); } } catch (err) { next(err); }
} }
); );
// 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 // DELETE /api/daily-statuses/:id
router.delete( router.delete(
'/:id', '/:id',
auditMiddleware('daily_statuses', prisma.dailyStatus), auditMiddleware('daily_statuses', prisma.dailyStatus),
async (req, res, next) => { async (req, res, next) => {
try { 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 } }); 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(); res.status(204).send();
} catch (err) { next(err); } } catch (err) { next(err); }
} }
+300 -12
View File
@@ -1,9 +1,11 @@
const router = require('express').Router(); const router = require('express').Router();
const { body } = require('express-validator'); const { body } = require('express-validator');
const prisma = require('../lib/prisma'); const prisma = require('../lib/prisma');
const cache = require('../lib/cache');
const auditMiddleware = require('../middleware/auditMiddleware'); const auditMiddleware = require('../middleware/auditMiddleware');
const { validateRequest } = require('../middleware/errorHandler'); const { validateRequest } = require('../middleware/errorHandler');
const { resolveTemplate, DEFAULT_TEMPLATE } = require('../lib/defaultTemplate'); const { v4: uuidv4 } = require('uuid');
const { resolveTemplate, DEFAULT_TEMPLATE, UUID_RE } = require('../lib/defaultTemplate');
const experimentValidation = [ const experimentValidation = [
body('title').trim().notEmpty().withMessage('Title is required').isLength({ max: 255 }).withMessage('Title must be ≤255 characters'), body('title').trim().notEmpty().withMessage('Title is required').isLength({ max: 255 }).withMessage('Title must be ≤255 characters'),
@@ -11,6 +13,7 @@ const experimentValidation = [
// ── Template validation helpers ──────────────────────────────────────────────── // ── 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 BUILTIN_KEYS = new Set(DEFAULT_TEMPLATE.map((f) => f.key));
const VALID_TYPES = new Set(['text', 'textarea']); const VALID_TYPES = new Set(['text', 'textarea']);
@@ -18,31 +21,85 @@ function validateTemplate(template) {
if (!Array.isArray(template)) return 'template must be an array'; if (!Array.isArray(template)) return 'template must be an array';
if (template.length > 50) return 'template may have at most 50 fields'; if (template.length > 50) return 'template may have at most 50 fields';
const keys = new Set(); const seenFieldIds = new Set();
const seenKeys = new Set();
for (const field of template) { 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 (!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 (!/^[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 (field.key.length > 64) return `key "${field.key}" must be ≤64 characters`;
if (keys.has(field.key)) return `duplicate key "${field.key}"`; if (seenKeys.has(field.key)) return `duplicate key "${field.key}"`;
keys.add(field.key); seenKeys.add(field.key);
// ── label ──────────────────────────────────────────────────────────────────
if (!field.label || typeof field.label !== 'string' || !field.label.trim()) if (!field.label || typeof field.label !== 'string' || !field.label.trim())
return `field "${field.key}" must have a non-empty label`; 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 (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 (!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 (typeof field.active !== 'boolean') return `field "${field.key}" must have a boolean active flag`;
// Builtin keys cannot change their builtin status
if (BUILTIN_KEYS.has(field.key) && field.builtin !== true) if (BUILTIN_KEYS.has(field.key) && field.builtin !== true)
return `field "${field.key}" is a builtin field and cannot be made non-builtin`; return `field "${field.key}" is a builtin field and cannot be made non-builtin`;
if (!BUILTIN_KEYS.has(field.key) && field.builtin !== false) if (!BUILTIN_KEYS.has(field.key) && field.builtin !== false)
return `custom field "${field.key}" must have 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; 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 ──────────────────────────────────────────────────────────── // ── Experiment CRUD ────────────────────────────────────────────────────────────
// GET /api/experiments // GET /api/experiments
@@ -64,8 +121,7 @@ router.get('/:id', async (req, res, next) => {
include: { animals: true }, include: { animals: true },
}); });
if (!experiment) return res.status(404).json({ error: 'Experiment not found' }); if (!experiment) return res.status(404).json({ error: 'Experiment not found' });
// Always return resolved template (never raw []) experiment.template = ensureFieldIds(resolveTemplate(experiment.template));
experiment.template = resolveTemplate(experiment.template);
res.json(experiment); res.json(experiment);
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
@@ -78,7 +134,7 @@ router.post('/', experimentValidation, validateRequest, async (req, res, next) =
await prisma.auditLog.create({ 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 = resolveTemplate(experiment.template); experiment.template = ensureFieldIds(resolveTemplate(experiment.template));
res.status(201).json(experiment); res.status(201).json(experiment);
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
@@ -93,7 +149,7 @@ router.put(
try { try {
const { title } = req.body; 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 = resolveTemplate(experiment.template); experiment.template = ensureFieldIds(resolveTemplate(experiment.template));
res.json(experiment); res.json(experiment);
} catch (err) { next(err); } } catch (err) { next(err); }
} }
@@ -111,6 +167,87 @@ router.delete(
} }
); );
// ── 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 ───────────────────────────────────────────────────────── // ── Template endpoints ─────────────────────────────────────────────────────────
// GET /api/experiments/:id/template // GET /api/experiments/:id/template
@@ -121,7 +258,7 @@ router.get('/:id/template', async (req, res, next) => {
select: { id: true, template: true }, select: { id: true, template: true },
}); });
if (!experiment) return res.status(404).json({ error: 'Experiment not found' }); if (!experiment) return res.status(404).json({ error: 'Experiment not found' });
res.json(resolveTemplate(experiment.template)); res.json(ensureFieldIds(resolveTemplate(experiment.template)));
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
@@ -135,7 +272,7 @@ router.put('/:id/template', async (req, res, next) => {
const validationError = validateTemplate(template); const validationError = validateTemplate(template);
if (validationError) return res.status(422).json({ error: validationError }); if (validationError) return res.status(422).json({ error: validationError });
const before = resolveTemplate(experiment.template); const before = ensureFieldIds(resolveTemplate(experiment.template));
const updated = await prisma.experiment.update({ const updated = await prisma.experiment.update({
where: { id: req.params.id }, where: { id: req.params.id },
data: { template }, data: { template },
@@ -154,4 +291,155 @@ router.put('/:id/template', async (req, res, next) => {
} catch (err) { next(err); } } 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; 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;
+70
View File
@@ -118,3 +118,73 @@ describe('DELETE /api/animals/:id', () => {
); );
}); });
}); });
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/);
});
});
+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/);
});
});
@@ -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,
});
});
});
+46
View File
@@ -136,3 +136,49 @@ describe('GET /health', () => {
expect(res.body.status).toBe('ok'); 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();
});
});
+3
View File
@@ -9,6 +9,8 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-expuser} POSTGRES_USER: ${POSTGRES_USER:-expuser}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-exppassword} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-exppassword}
POSTGRES_DB: ${POSTGRES_DB:-experiments_db} POSTGRES_DB: ${POSTGRES_DB:-experiments_db}
TZ: America/New_York
command: ["postgres", "-c", "timezone=America/New_York"]
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
# Port NOT exposed to host — only reachable by backend via internal Docker network # Port NOT exposed to host — only reachable by backend via internal Docker network
@@ -34,6 +36,7 @@ services:
NODE_ENV: production NODE_ENV: production
PORT: 3001 PORT: 3001
CORS_ORIGIN: "http://localhost:52867" CORS_ORIGIN: "http://localhost:52867"
TZ: America/New_York
# Non-root user set in Dockerfile; entrypoint runs migrations then starts server # Non-root user set in Dockerfile; entrypoint runs migrations then starts server
entrypoint: ["/bin/sh", "/app/docker-entrypoint.sh"] entrypoint: ["/bin/sh", "/app/docker-entrypoint.sh"]
ports: ports:
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,668 @@
# Subject-Series 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:** Replace the export modal's assignable-axes UI with three pickers — X field (rows), Data value (cells), and Group by — where columns are always the subjects, matching the cross-subject metrics plot.
**Architecture:** Add X-coordinate helpers to `dataExport.js` (`numericFieldVal`, `listSampleMembers`, `buildSampleIndex`) and a focused `buildSubjectSeriesMatrix` that emits the existing `toCSV` shape. Rewrite `ExportDataModal.jsx` to the three pickers. Then delete the now-dead assignable-axes machinery.
**Tech Stack:** React 18, plain ES modules, Jest + `@testing-library/react` (jsdom). No new dependencies.
**Spec:** `docs/superpowers/specs/2026-07-19-subject-series-export-design.md`
## Global Constraints
- `dataExport.js` stays pure: no React, no I/O.
- Cell value semantics: `0` and `''` are **real** values; only genuinely-missing lookups render blank (`''`), via the existing `hasValue` + `extractValue`.
- CSV: LF (`\n`) line endings; escape every cell via the existing `escapeCsvCell`.
- **X = `Date`** → rows are distinct `YYYY-MM-DD` dates, sorted.
- **X = `# Days Reach`** (`'__days__'`) → rows are `1, 2, 3, …`; the max over subjects of that subject's count of recorded statuses (a status must have `animal_id` and `date`). A cell for `(subject, N)` reads the subject's Nth recorded day, counting **all** records sorted ascending by date (per-status ordinal, Day 1 = earliest).
- **X = daily field** (its `fieldId`) → rows are the distinct **numeric** values (`numericFieldVal`), sorted ascending; non-numeric excluded. A cell for `(subject, v)` reads that subject's first status (by date) whose field equals `v`.
- Columns are **always** the subjects (`listSubjectMembers`), clustered by group then name. Entirely-blank sample rows are dropped; subject columns are always kept.
- Group by defaults to the saved field (`groupField` prop), validated against `__none__`/`__name__`/`__id__`/active `subjectTemplate` fields; coerced to `__none__` when unset or stale.
- CSV: line 1 is `Data,<metric label>`; blank line; a `Group,…` row only when grouping (missing group → `—`); header `= <X label>,<subject headers…>`; data rows `= <X value>,<values…>`. Filename via existing `csvFilename(title, dataLabel)`.
- Default X = `Date`; default Data = first parameter (`Total attempts`).
- Run tests from `frontend/`: `npx jest tests/dataExport.test.js`, `npx jest tests/ExportDataModal.test.jsx`, full `npm test`.
- Two suites (`tests/ExperimentCalendar.test.jsx`, `tests/ExperimentDayView.test.jsx`, 31 tests) fail for a PRE-EXISTING, unrelated reason and must be left as-is; changes must introduce no new failures.
---
### Task 1: X-coordinate helpers
**Files:**
- Modify: `frontend/src/lib/dataExport.js` (add three exports; keep everything else for now)
- Test: `frontend/tests/dataExport.test.js`
**Interfaces:**
- Consumes: existing private `dateKey(date)`.
- Produces:
- `numericFieldVal(status, field): number|null``field.builtin ? status[field.key] : status.custom_fields[field.fieldId]`, `parseFloat`, non-numeric → null.
- `listSampleMembers(statuses, xField, dailyTemplate): Array<{ id, label, sampleValue }>` — row members for the chosen X (`xField` is `'__date__'`, `'__days__'`, or a daily `fieldId`).
- `buildSampleIndex(statuses, xField, dailyTemplate): Map<animalId, Map<sampleValue, status>>` — first status per `(subject, sampleValue)` wins, per-subject date-ordered.
- [ ] **Step 1: Write the failing tests**
Append to `frontend/tests/dataExport.test.js`:
```js
import { numericFieldVal, listSampleMembers, buildSampleIndex } from '../src/lib/dataExport';
const sDaily = [{ fieldId: 'c-w', key: 'w', label: 'Weight', builtin: false, active: true }];
const sStatuses = [
{ animal_id: 'a1', date: '2026-07-01T00:00:00Z', custom_fields: { 'c-w': '250' } },
{ animal_id: 'a1', date: '2026-07-03T00:00:00Z', custom_fields: { 'c-w': '260' } },
{ animal_id: 'a2', date: '2026-07-01T00:00:00Z', custom_fields: { 'c-w': '250' } },
];
describe('numericFieldVal', () => {
it('reads builtin via key and custom via fieldId, parsed to number', () => {
expect(numericFieldVal({ weight: '250' }, { builtin: true, key: 'weight' })).toBe(250);
expect(numericFieldVal({ custom_fields: { 'c-w': '12.5' } }, { builtin: false, fieldId: 'c-w' })).toBe(12.5);
});
it('returns null for non-numeric, missing, or no field', () => {
expect(numericFieldVal({ custom_fields: { 'c-w': 'abc' } }, { builtin: false, fieldId: 'c-w' })).toBeNull();
expect(numericFieldVal({}, { builtin: true, key: 'weight' })).toBeNull();
expect(numericFieldVal({ weight: 1 }, null)).toBeNull();
});
});
describe('listSampleMembers', () => {
it('date: distinct dates sorted', () => {
expect(listSampleMembers(sStatuses, '__date__', sDaily).map((m) => m.sampleValue)).toEqual(['2026-07-01', '2026-07-03']);
});
it('# days reach: 1..max records per subject', () => {
expect(listSampleMembers(sStatuses, '__days__', sDaily).map((m) => m.sampleValue)).toEqual([1, 2]);
});
it('daily field: distinct numeric values sorted ascending', () => {
expect(listSampleMembers(sStatuses, 'c-w', sDaily).map((m) => m.sampleValue)).toEqual([250, 260]);
});
});
describe('buildSampleIndex', () => {
it('date coord maps animalId -> dateKey -> status', () => {
const idx = buildSampleIndex(sStatuses, '__date__', sDaily);
expect(idx.get('a1').get('2026-07-03').date).toContain('2026-07-03');
});
it('# days reach assigns per-subject ordinals in date order', () => {
const idx = buildSampleIndex(sStatuses, '__days__', sDaily);
expect(idx.get('a1').get(1).date).toContain('2026-07-01');
expect(idx.get('a1').get(2).date).toContain('2026-07-03');
expect(idx.get('a2').get(1).date).toContain('2026-07-01');
expect(idx.get('a2').has(2)).toBe(false);
});
it('daily field maps numeric value to first status by date', () => {
const idx = buildSampleIndex(sStatuses, 'c-w', sDaily);
expect(idx.get('a1').get(250).date).toContain('2026-07-01');
expect(idx.get('a1').get(260).date).toContain('2026-07-03');
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx jest tests/dataExport.test.js -t "listSampleMembers"`
Expected: FAIL — `listSampleMembers is not defined`.
- [ ] **Step 3: Write minimal implementation**
Add to `frontend/src/lib/dataExport.js` (after `listExportableParams`, before the private `hasValue`/`dateKey` — note `dateKey` is defined lower in the file but hoisted as a function declaration, so referencing it here is fine):
```js
// Numeric value of a daily field on one status (mirrors the metrics plot's helper).
export 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 Number.isNaN(n) ? null : n;
}
// Ordered row members for the chosen X coordinate.
// xField: '__date__' | '__days__' | <daily fieldId>
export function listSampleMembers(statuses, xField, dailyTemplate) {
const list = statuses ?? [];
if (xField === '__date__') {
const keys = new Set();
for (const s of list) if (s?.date) keys.add(dateKey(s.date));
return [...keys].sort().map((k) => ({ id: k, label: k, sampleValue: k }));
}
if (xField === '__days__') {
const counts = new Map();
for (const s of list) {
if (s?.animal_id == null || !s?.date) continue;
counts.set(s.animal_id, (counts.get(s.animal_id) ?? 0) + 1);
}
const max = counts.size ? Math.max(...counts.values()) : 0;
return Array.from({ length: max }, (_, i) => ({ id: String(i + 1), label: String(i + 1), sampleValue: i + 1 }));
}
const field = (dailyTemplate ?? []).find((f) => f.fieldId === xField);
const vals = new Set();
for (const s of list) {
const v = numericFieldVal(s, field);
if (v !== null) vals.add(v);
}
return [...vals].sort((a, b) => a - b).map((v) => ({ id: String(v), label: String(v), sampleValue: v }));
}
// animalId -> Map(sampleValue -> status). First status per (subject, sampleValue) wins,
// scanning each subject's statuses in ascending date order.
export function buildSampleIndex(statuses, xField, dailyTemplate) {
const bySubject = new Map();
for (const s of statuses ?? []) {
if (s?.animal_id == null || !s?.date) continue;
if (!bySubject.has(s.animal_id)) bySubject.set(s.animal_id, []);
bySubject.get(s.animal_id).push(s);
}
const field = xField !== '__date__' && xField !== '__days__'
? (dailyTemplate ?? []).find((f) => f.fieldId === xField)
: null;
const index = new Map();
for (const [animalId, subjStatuses] of bySubject) {
const ordered = [...subjStatuses].sort((a, b) => dateKey(a.date).localeCompare(dateKey(b.date)));
const m = new Map();
ordered.forEach((s, i) => {
let key;
if (xField === '__date__') key = dateKey(s.date);
else if (xField === '__days__') key = i + 1;
else { const v = numericFieldVal(s, field); if (v === null) return; key = v; }
if (!m.has(key)) m.set(key, s);
});
index.set(animalId, m);
}
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): X-coordinate helpers (date, # days reach, daily field)"
```
---
### Task 2: buildSubjectSeriesMatrix
**Files:**
- Modify: `frontend/src/lib/dataExport.js` (add `buildSubjectSeriesMatrix` + private `xFieldLabel`)
- Test: `frontend/tests/dataExport.test.js`
**Interfaces:**
- Consumes: `listSubjectMembers`, `listSampleMembers`, `buildSampleIndex`, `extractValue`, private `hasValue`.
- Produces: `buildSubjectSeriesMatrix({ xField, dataParam, groupField }, { statuses, animals, dailyTemplate })``{ corner, context: { label: 'Data', value }, groupAxis: 'col'|null, columns: [{ id, label, animalId, group }], rows: [{ member: { id, label, sampleValue }, values: [value|''] }] }` — the same shape the existing `toCSV` consumes (`groupAxis: 'col'` triggers its group-row branch).
- [ ] **Step 1: Write the failing tests**
Append to `frontend/tests/dataExport.test.js`:
```js
import { buildSubjectSeriesMatrix } from '../src/lib/dataExport';
const ssAnimals = [
{ 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 ssStatuses = [
{ animal_id: 'a1', date: '2026-07-01T00:00:00Z', analysis_summary: { total: 5 } },
{ animal_id: 'a1', date: '2026-07-02T00:00:00Z', analysis_summary: { total: 7 } },
{ animal_id: 'a2', date: '2026-07-01T00:00:00Z', analysis_summary: { total: 9 } },
];
const totalParam = { id: '__total__', label: 'Total attempts', kind: 'total', group: 'metrics' };
describe('buildSubjectSeriesMatrix', () => {
it('date X: subjects as columns, date rows, blank where missing, context names the metric', () => {
const m = buildSubjectSeriesMatrix(
{ xField: '__date__', dataParam: totalParam, groupField: '__none__' },
{ statuses: ssStatuses, animals: ssAnimals, dailyTemplate: [] },
);
expect(m.corner).toBe('Date');
expect(m.context).toEqual({ label: 'Data', value: 'Total attempts' });
expect(m.groupAxis).toBeNull();
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, '']);
});
it('# days reach X: rows align each subject by day ordinal', () => {
const m = buildSubjectSeriesMatrix(
{ xField: '__days__', dataParam: totalParam, groupField: '__none__' },
{ statuses: ssStatuses, animals: ssAnimals, dailyTemplate: [] },
);
expect(m.corner).toBe('# Days Reach');
expect(m.rows.map((r) => r.member.label)).toEqual(['1', '2']);
expect(m.rows[0].values).toEqual([5, 9]);
expect(m.rows[1].values).toEqual([7, '']);
});
it('grouping sets groupAxis col and clusters subjects', () => {
const m = buildSubjectSeriesMatrix(
{ xField: '__date__', dataParam: totalParam, groupField: 'grp' },
{ statuses: ssStatuses, animals: ssAnimals, dailyTemplate: [] },
);
expect(m.groupAxis).toBe('col');
expect(m.columns.map((c) => [c.group, c.label])).toEqual([['Control', 'Alpha'], ['Drug', 'Beta']]);
});
it('drops entirely-blank rows', () => {
const statuses = [
{ animal_id: 'a1', date: '2026-07-01T00:00:00Z', analysis_summary: { total: 5 } },
{ animal_id: 'a1', date: '2026-07-05T00:00:00Z', analysis_summary: null },
];
const m = buildSubjectSeriesMatrix(
{ xField: '__date__', dataParam: totalParam, groupField: '__none__' },
{ statuses, animals: ssAnimals, dailyTemplate: [] },
);
expect(m.rows.map((r) => r.member.label)).toEqual(['2026-07-01']);
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx jest tests/dataExport.test.js -t "buildSubjectSeriesMatrix"`
Expected: FAIL — `buildSubjectSeriesMatrix is not defined`.
- [ ] **Step 3: Write minimal implementation**
Add to `frontend/src/lib/dataExport.js` (after `buildSampleIndex`):
```js
function xFieldLabel(xField, dailyTemplate) {
if (xField === '__date__') return 'Date';
if (xField === '__days__') return '# Days Reach';
return (dailyTemplate ?? []).find((f) => f.fieldId === xField)?.label ?? '';
}
// Subjects as columns, X-field values as rows, one Data value per cell.
// Returns the { context, corner, groupAxis, columns, rows } shape toCSV consumes.
export function buildSubjectSeriesMatrix({ xField, dataParam, groupField }, { statuses, animals, dailyTemplate }) {
const columns = listSubjectMembers(animals, groupField);
const rowMembers = listSampleMembers(statuses, xField, dailyTemplate);
const index = buildSampleIndex(statuses, xField, dailyTemplate);
const cellValue = (sampleValue, animalId) => {
const status = index.get(animalId)?.get(sampleValue);
if (!status || !dataParam) return '';
const v = extractValue(status, dataParam);
return hasValue(v) ? v : '';
};
let rows = rowMembers.map((rm) => ({
member: rm,
values: columns.map((c) => cellValue(rm.sampleValue, c.animalId)),
}));
rows = rows.filter((r) => r.values.some((v) => v !== '')); // drop entirely-blank sample rows
const grouped = !!groupField && groupField !== '__none__';
return {
corner: xFieldLabel(xField, dailyTemplate),
context: { label: 'Data', value: dataParam?.label ?? '' },
groupAxis: grouped ? 'col' : null,
columns,
rows,
};
}
```
- [ ] **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): buildSubjectSeriesMatrix — subjects as columns over an X field"
```
---
### Task 3: Rewrite ExportDataModal to the three pickers
**Files:**
- Modify: `frontend/src/components/ExportDataModal.jsx` (full body rewrite; keep `triggerDownload`)
- Test: `frontend/tests/ExportDataModal.test.jsx` (rewrite)
**Interfaces:**
- Consumes: `listExportableParams`, `buildSubjectSeriesMatrix`, `toCSV`, `csvFilename` from `../lib/dataExport`; `experimentsApi.getDailyStatuses`.
- Props unchanged: `experimentTitle`, `experimentId`, `dailyTemplate`, `animals`, `subjectTemplate` (default `[]`), `groupField` (default `'__none__'`), `onClose`.
- Produces: modal with three selects — X (rows), Data (cells), Group by.
- [ ] **Step 1: Rewrite the component test**
Replace the entire contents of `frontend/tests/ExportDataModal.test.jsx` with:
```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:00Z', analysis_summary: { total: 5, success_rate: 0.5 } },
{ animal_id: 'a1', date: '2026-07-02T00:00:00Z', analysis_summary: { total: 7, success_rate: 0.7 } },
{ animal_id: 'a2', date: '2026-07-01T00:00:00Z', analysis_summary: { total: 9, success_rate: 0.9 } },
];
const subjectTemplate = [{ fieldId: 'grp', label: 'Group', active: true }];
beforeEach(() => {
jest.clearAllMocks();
client.experimentsApi.getDailyStatuses.mockResolvedValue(statuses);
});
// Intercept the Blob handed to URL.createObjectURL so tests can read the real CSV.
// Also stub anchor.click() — jsdom treats a click on <a href="blob:..."> as an
// unimplemented navigation that throws async and can flake a later test.
function mockDownload() {
let blob = null;
const origCreate = global.URL.createObjectURL;
const origRevoke = global.URL.revokeObjectURL;
const origClick = window.HTMLAnchorElement.prototype.click;
global.URL.createObjectURL = jest.fn((b) => { blob = b; return 'blob:mock'; });
global.URL.revokeObjectURL = jest.fn();
window.HTMLAnchorElement.prototype.click = jest.fn();
return {
getBlob: () => blob,
restore: async () => {
await new Promise((r) => setTimeout(r, 0)); // flush handleExport's setTimeout revoke
global.URL.createObjectURL = origCreate;
global.URL.revokeObjectURL = origRevoke;
window.HTMLAnchorElement.prototype.click = origClick;
},
};
}
function readBlobText(blob) {
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(fr.result);
fr.onerror = reject;
fr.readAsText(blob);
});
}
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('renders X / Data / Group by pickers; X defaults to Date', async () => {
renderModal();
expect(await screen.findByLabelText('X axis (rows)')).toHaveValue('__date__');
expect(screen.getByLabelText('Data (cells)')).toBeInTheDocument();
expect(screen.getByLabelText('Group by')).toHaveValue('__none__');
await waitFor(() => expect(screen.getByText(/2 rows × 2 subjects/i)).toBeInTheDocument());
});
it('default export (X=Date, Data=Total attempts) has subject columns, date rows, blank where missing', async () => {
const dl = mockDownload();
renderModal();
await screen.findByLabelText('X axis (rows)');
await waitFor(() => expect(screen.getByText(/2 rows/i)).toBeInTheDocument());
fireEvent.click(screen.getByText('Export CSV'));
const text = await readBlobText(dl.getBlob());
expect(text).toBe('Data,Total attempts\n\nDate,Alpha,Beta\n2026-07-01,5,9\n2026-07-02,7,');
await dl.restore();
});
it('switching X to # Days Reach makes rows the day ordinals', async () => {
const dl = mockDownload();
renderModal();
fireEvent.change(await screen.findByLabelText('X axis (rows)'), { target: { value: '__days__' } });
await waitFor(() => expect(screen.getByText(/2 rows/i)).toBeInTheDocument());
fireEvent.click(screen.getByText('Export CSV'));
const text = await readBlobText(dl.getBlob());
expect(text).toBe('Data,Total attempts\n\n# Days Reach,Alpha,Beta\n1,5,9\n2,7,');
await dl.restore();
});
it('a valid group field adds a Group row and clusters subjects', async () => {
const dl = mockDownload();
renderModal({ groupField: 'grp' });
await screen.findByLabelText('X axis (rows)');
await waitFor(() => expect(screen.getByText(/2 rows/i)).toBeInTheDocument());
fireEvent.click(screen.getByText('Export CSV'));
const text = await readBlobText(dl.getBlob());
expect(text).toMatch(/^Group,Control,Drug$/m);
await dl.restore();
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `npx jest tests/ExportDataModal.test.jsx`
Expected: FAIL — old modal has no `X axis (rows)` / `Data (cells)` labels.
- [ ] **Step 3: Rewrite the component**
Replace the entire contents of `frontend/src/components/ExportDataModal.jsx` with:
```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, buildSubjectSeriesMatrix, toCSV, csvFilename } from '../lib/dataExport';
const PARAM_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, subjectTemplate = [], groupField = '__none__', onClose,
}) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [statuses, setStatuses] = useState([]);
const [xField, setXField] = useState('__date__');
const [dataId, setDataId] = useState(null);
const [groupBy, setGroupBy] = useState(() => {
const valid = groupField === '__none__' || groupField === '__name__' || groupField === '__id__'
|| subjectTemplate.some((f) => f.active && f.fieldId === groupField);
return valid ? 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 params = useMemo(() => listExportableParams(dailyTemplate, statuses), [dailyTemplate, statuses]);
const effDataId = dataId ?? params[0]?.id ?? null;
const dataParam = params.find((p) => p.id === effDataId) ?? params[0] ?? null;
const activeDaily = useMemo(() => (dailyTemplate ?? []).filter((f) => f.active), [dailyTemplate]);
const xOptions = useMemo(() => [
{ id: '__date__', label: 'Date' },
{ id: '__days__', label: '# Days Reach' },
...activeDaily.map((f) => ({ id: f.fieldId, label: f.label })),
], [activeDaily]);
const matrix = useMemo(
() => (dataParam
? buildSubjectSeriesMatrix({ xField, dataParam, groupField: groupBy }, { statuses, animals, dailyTemplate })
: { columns: [], rows: [], context: { label: 'Data', value: '' }, corner: '', groupAxis: null }),
[xField, dataParam, groupBy, statuses, animals, dailyTemplate],
);
const hasData = matrix.rows.length > 0;
const groupedParams = 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 || !dataParam) return;
triggerDownload(csvFilename(experimentTitle, dataParam.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';
return (
<div className="space-y-4">
<p className="text-sm text-gray-500">
One value per subject over an X axis each column is a subject, each row an X value. Blank where a subject has no value.
</p>
<div>
<label htmlFor="export-x" className="block text-xs font-medium text-gray-500 mb-1">X axis (rows)</label>
<select id="export-x" value={xField} onChange={(e) => setXField(e.target.value)} className={selectCls}>
{xOptions.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
</div>
<div>
<label htmlFor="export-data" className="block text-xs font-medium text-gray-500 mb-1">Data (cells)</label>
<select id="export-data" value={effDataId ?? ''} onChange={(e) => setDataId(e.target.value)} className={selectCls}>
{groupedParams.map((g) => (
<optgroup key={g.group} label={PARAM_GROUP_LABELS[g.group] ?? g.group}>
{g.items.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
</optgroup>
))}
</select>
</div>
<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} row${matrix.rows.length !== 1 ? 's' : ''} × ${matrix.columns.length} subject${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>
);
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `npx jest tests/ExportDataModal.test.jsx`
Expected: PASS (all four cases).
- [ ] **Step 5: Run the full suite**
Run: `npm test`
Expected: `dataExport` + `ExportDataModal` green; only the two known pre-existing suites fail; no new failures.
- [ ] **Step 6: Commit**
```bash
git add frontend/src/components/ExportDataModal.jsx frontend/tests/ExportDataModal.test.jsx
git commit -m "feat(export): modal becomes X / Data / Group by pickers, subjects as columns"
```
---
### Task 4: Remove the dead assignable-axes code
**Files:**
- Modify: `frontend/src/lib/dataExport.js` (delete unused exports)
- Test: `frontend/tests/dataExport.test.js` (delete their describe blocks + imports)
**Interfaces:**
- Removes (now unused after Task 3): `EXPORT_DIMENSIONS`, `dimLabel`, `otherDimension`, `listDateMembers`, `buildStatusIndex`, `listParameterMembers`, `listDimensionMembers`, and `buildMatrix`.
- Keeps: `extractValue`, `listExportableParams`, `subjectGroupValue`, `listSubjectMembers`, `numericFieldVal`, `listSampleMembers`, `buildSampleIndex`, `buildSubjectSeriesMatrix`, `toCSV`, `csvFilename`, and the private `hasValue`, `dateKey`, `escapeCsvCell`, `xFieldLabel`.
- [ ] **Step 1: Delete the dead source exports**
In `frontend/src/lib/dataExport.js`, delete these eight exported definitions entirely (they are only used by each other and the old modal, now replaced): `EXPORT_DIMENSIONS`, `dimLabel`, `otherDimension`, `listDateMembers`, `buildStatusIndex`, `listParameterMembers`, `listDimensionMembers`, `buildMatrix`. Also update the top-of-file comment to describe the subject-series export (drop the "dimension-agnostic buildMatrix" wording). Keep `dateKey`, `hasValue`, `escapeCsvCell`, and everything listed under "Keeps" above.
- [ ] **Step 2: Delete their tests**
In `frontend/tests/dataExport.test.js`, delete the describe blocks and their now-unused import identifiers for the removed functions: the `describe('dimension primitives', …)`, `describe('subjectGroupValue', …)` KEEP (subjectGroupValue is kept — do NOT delete it), `describe('listDateMembers', …)`, `describe('buildStatusIndex', …)`, `describe('listParameterMembers / listDimensionMembers', …)`, and `describe('buildMatrix', …)` blocks. Remove `EXPORT_DIMENSIONS`, `dimLabel`, `otherDimension`, `listDateMembers`, `buildStatusIndex`, `listDimensionMembers`, `listParameterMembers`, and `buildMatrix` from the file's `import` statements, leaving the imports for kept functions (`extractValue`, `listExportableParams`, `subjectGroupValue`, `listSubjectMembers`, `numericFieldVal`, `listSampleMembers`, `buildSampleIndex`, `buildSubjectSeriesMatrix`, `toCSV`, `csvFilename`).
- [ ] **Step 3: Verify nothing references the removed names**
Run: `grep -rnE "buildMatrix|EXPORT_DIMENSIONS|listDimensionMembers|buildStatusIndex|listDateMembers|listParameterMembers|otherDimension|dimLabel" frontend/src frontend/tests`
Expected: no output (all references gone).
- [ ] **Step 4: Run the suites**
Run: `npx jest tests/dataExport.test.js tests/ExportDataModal.test.jsx`
Expected: PASS. Then `npm test` — only the two known pre-existing suites fail, no new failures.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/lib/dataExport.js frontend/tests/dataExport.test.js
git commit -m "refactor(export): remove dead assignable-axes helpers and their tests"
```
---
## Self-Review Notes
- **Spec coverage:** three pickers (Task 3) · X = Date/# Days Reach/daily field with the exact semantics (Tasks 12) · subjects always columns, clustered by group, blank rows dropped (Task 2) · Group by validated/defaulted (Task 3) · CSV `Data,<metric>` + group row + X-corner (Tasks 23, reusing `toCSV`) · assignable-axes UI/machinery removed (Tasks 34). All covered.
- **Type consistency:** sample member `{ id, label, sampleValue }`, subject column `{ id, label, animalId, group }`, and matrix `{ context, corner, groupAxis, columns, rows }` are used identically across Tasks 13 and match the existing `toCSV`.
- **Out of scope (per spec):** assignable columns/pinned, subject subsetting, analyzed-only day counting, footer/repeat line.
@@ -0,0 +1,151 @@
# MATLAB tDCS Analysis Port — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Port the Python tDCS GLM analysis (`analysis/tdcs_glm.py`) to MATLAB under `analysis/matlab/`, driven by raw app exports, with 6 self-contained `switch/case` scenarios that print raw GLM output + interpretation, plus a `matlab.unittest` suite.
**Architecture:** `fitglme` (GLMM, per-subject random intercept) replaces the Python GEE. Two raw matrices (Success, Total attempts) are parsed and joined to a long table; a curated 12-animal map defines groups; each scenario applies a merge map + day window, fits the models, and reports.
**Tech Stack:** MATLAB R2025b + Statistics and Machine Learning Toolbox (installed at `~/matlab`).
## Global Constraints
- **MATLAB is NOT runnable in this environment until the user's license is activated** (`~/matlab/bin/activate_matlab.sh`). Write all code and tests; they are executed later via `~/matlab/bin/matlab -batch "run_tests"`. In each task, "run the test" means: **the test is authored and self-consistent; note it is unrun pending license.** Do not claim tests pass.
- Reference implementation: `analysis/tdcs_glm.py` and `analysis/METHODS.md`. Port equivalent behavior; do not change the Python.
- Reference group = **`Electrode-Box-B2`**; `day_c` = mean-centered day; `day_c2 = day_c.^2`.
- Analysis restricted to **day ≥ 0**. The **rate model only** drops `total==0` sessions; the count model keeps them.
- **Curated 12-animal group map** (by animal name; all other animals in the raw files are dropped):
- Naive: `Vu-vuong, Khoai-lang-2, Khoai-tay-2, OM-2`
- Electrode-Box-A: `Khoai-tay-1`
- Electrode-Box-A2: `Banh-mi-2, Egg-tart-2, Root-beer-2`
- Electrode-Box-B2: `Banh-mi-1, Egg-tart-1, Root-beer-1`
- Right-Electrode: `Khoai-lang-1`
- Merge maps: `mergeA2 = {Electrode-Box-A→Electrode-Box-A2, Right-Electrode→Electrode-Box-B2}`; `mergeB2 = {Electrode-Box-A→Electrode-Box-B2, Right-Electrode→Electrode-Box-B2}`; `unmerged = {}` (5 groups, classify unknowns).
- Raw files already generated at `analysis/matlab/raw/raw_success.csv` and `raw_total.csv` (app export format: `Data,<metric>` / blank / `Group,…` / `# Days Reach,<subjects>` / `<day>,<values>`). Contain all 15 animals incl. negative days.
- Load-check cells (post-join, day≥0): `Vu-vuong` day 0 → success=18, total=61; `Banh-mi-1` day 0 → success=13, total=84.
---
### Task 1: Data loading + curated map
**Files:**
- Create: `analysis/matlab/tdcs_config.m` (constants: analysis group map, merge maps, ref group)
- Create: `analysis/matlab/tdcs_load_data.m`
- Create: `analysis/matlab/tests/tLoadData.m`
**Interfaces:**
- Produces `cfg = tdcs_config()` returning a struct: `cfg.groups` (containers.Map name→group for the 12), `cfg.ref='Electrode-Box-B2'`, `cfg.mergeA2`, `cfg.mergeB2` (containers.Map), `cfg.anchorLow='Electrode-Box-A2'`, `cfg.anchorHigh='Electrode-Box-B2'`, `cfg.unknowns={'Electrode-Box-A','Right-Electrode'}`.
- Produces `T = tdcs_load_data()` returning a table with variables `subject` (string), `group` (categorical), `day` (double), `success` (double), `total` (double); only the 12 curated animals; only `day >= 0`.
**Steps:**
- [ ] **Step 1: Write `tdcs_config.m`** — return the struct above using the Global Constraints values. `containers.Map` for the name→group map (all 12 entries) and the two merge maps.
- [ ] **Step 2: Write the failing test `tests/tLoadData.m`** (matlab.unittest). Assertions:
- `T` has 12 unique subjects; `height(T)` matches the non-blank day≥0 rows.
- Group subject counts: Naive=4, Electrode-Box-A2=3, Electrode-Box-B2=3, Electrode-Box-A=1, Right-Electrode=1.
- `T(T.subject=="Vu-vuong" & T.day==0, {'success','total'})` equals `[18 61]`.
- `T(T.subject=="Banh-mi-1" & T.day==0, {'success','total'})` equals `[13 84]`.
- No rows with `day < 0`.
- [ ] **Step 3: Write `tdcs_load_data.m`.** Port the CSV-matrix parsing:
- Read `raw/raw_success.csv` and `raw/raw_total.csv` as raw lines (`readlines` or `fileread`+`splitlines`).
- Line 1 = `Data,<metric>` (skip); line 2 blank (skip); line 3 = `Group,<…>` (skip — grouping comes from the curated map, not this row); line 4 = `# Days Reach,<subject headers>` → subject names (columns 2:end); lines 5+ = `<day>,<value…>`.
- Build long form: for each (day, subject) cell that is non-empty, emit a row. Parse success matrix and total matrix separately, then `innerjoin`/`outerjoin` on (subject, day). A cell present in one metric but blank in the other → treat blank as missing; keep the row only if success present (matches Python: rows come from Success; total is fully populated where success is).
- Apply the curated map: keep only subjects in `cfg.groups`; assign `group` from the map (overriding the file's Group row). Drop `day < 0`. Return the table.
- [ ] **Step 4: (Deferred) run** `~/matlab/bin/matlab -batch "run_tests"` once licensed; expect `tLoadData` green. For now, self-review the parsing against the two raw files and the load-check cells by reading.
- [ ] **Step 5: Commit** `git add analysis/matlab && git commit -m "matlab(tdcs): data loading + curated group map"`
---
### Task 2: Scenario derivation
**Files:**
- Create: `analysis/matlab/tdcs_scenario_data.m`
- Create: `analysis/matlab/tests/tScenarioData.m`
**Interfaces:**
- Consumes `tdcs_load_data`, `tdcs_config`.
- Produces `[S, meta] = tdcs_scenario_data(scenario)` where `scenario` ∈ {`unmerged_full`,`unmerged_d0_10`,`mergeA2_full`,`mergeA2_d0_10`,`mergeB2_full`,`mergeB2_d0_10`}. `S` = scenario table (group remapped, day window applied, `day_c`/`day_c2` added, `group` categorical with `cfg.ref` as the FIRST/reference category). `meta` = struct with `.mergeKey`, `.maxDay`, `.isUnmerged`. Also **saves** `S` to `analysis/matlab/derived/<scenario>.csv` (create the folder if missing).
**Steps:**
- [ ] **Step 1: Write the failing test `tests/tScenarioData.m`.** For each scenario, assert the per-group subject counts match this table, and that `max(S.day) <= maxDay`:
| scenario | subj/group | maxDay |
|---|---|---|
| unmerged_full | A=1,A2=3,B2=3,Naive=4,Right=1 | 26 |
| unmerged_d0_10 | A=1,A2=3,B2=3,Naive=4,Right=1 | 10 |
| mergeA2_full | A2=4,B2=4,Naive=4 | 26 |
| mergeA2_d0_10 | A2=4,B2=4,Naive=4 | 10 |
| mergeB2_full | A2=3,B2=5,Naive=4 | 26 |
| mergeB2_d0_10 | A2=3,B2=5,Naive=4 | 10 |
Also assert `derived/mergeB2_full.csv` is written and reloads to the same subject/group counts, and that the reference level of `S.group` is `Electrode-Box-B2`.
- [ ] **Step 2: Write `tdcs_scenario_data.m`.** Parse `scenario` into `(mergeKey, maxDay)` by splitting on `_full`/`_d0_10`. Load `T`, apply the merge `containers.Map` (relabel groups; `unmerged` = identity), filter `day <= maxDay`, center day, add `day_c2`, set `group` categorical with `reordercats` so `cfg.ref` is first. Write `derived/<scenario>.csv` via `writetable`.
- [ ] **Step 3: (Deferred) run tests once licensed.** Self-review the counts against the table by reading.
- [ ] **Step 4: Commit** `git commit -am "matlab(tdcs): scenario derivation + derived data export"`
---
### Task 3: Models (GLMM + per-animal + classification)
**Files:**
- Create: `analysis/matlab/tdcs_models.m`
- Create: `analysis/matlab/tests/tModels.m`
**Interfaces:**
- Consumes `tdcs_scenario_data`, `tdcs_config`.
- Produces `R = tdcs_models(S, meta, cfg)` returning a struct of results:
- `R.countGLME`, `R.rateGLME`, `R.learnGLME` — the `GeneralizedLinearMixedModel` objects.
- `R.perAnimal` — struct with `.countWelchP, .countMWUp, .rateWelchP, .rateMWUp, .nB, .nA` (B2 vs A2).
- `R.h2` — struct with `.countIRR, .countOneSidedP, .rateOR, .rateOneSidedP` (Box-A2 vs B2 from the GLMEs).
- `R.classify` — (unmerged only) struct per unknown with vs-A2 and vs-B2 ratios/p and a label; empty otherwise.
**Steps:**
- [ ] **Step 1: Write the failing test `tests/tModels.m`** using these Python golden values (per-animal tests are pure stats and must match to 3 decimals; GLME checks are loose direction/magnitude):
Per-animal (B2 vs A2), `assertEqual(..., 'AbsTol', 0.005)`:
| scenario | nB,nA | rateWelchP | rateMWUp | countWelchP | countMWUp |
|---|---|---|---|---|---|
| unmerged_full | 3,3 | 0.072 | 0.050 | 0.055 | 0.050 |
| mergeA2_full | 4,4 | 0.056 | 0.029 | 0.035 | 0.029 |
| mergeB2_full | 5,3 | 0.044 | 0.018 | 0.020 | 0.018 |
| unmerged_d0_10 | 3,3 | 0.070 | 0.050 | 0.041 | 0.050 |
| mergeA2_d0_10 | 4,4 | 0.069 | 0.057 | 0.023 | 0.014 |
| mergeB2_d0_10 | 5,3 | 0.094 | 0.071 | 0.023 | 0.018 |
GLME sanity (loose): for `mergeB2_full`, `R.h2.rateOR` in `[1.3, 1.7]` and `R.h2.rateOneSidedP < 0.05` and `R.h2.countIRR > 1`. For `unmerged_full`, `R.classify` has entries for both unknowns.
- [ ] **Step 2: Write `tdcs_models.m`.** Port the models:
- Count: `fitglme(S, 'success ~ group + day_c + day_c2 + (1|subject)', 'Distribution','Poisson','Link','log')`.
- Rate: drop `total==0` rows, then `fitglme(Sr, 'success ~ group + day_c + day_c2 + (1|subject)', 'Distribution','Binomial','BinomialSize',Sr.total,'Link','logit')`.
- Learning: `fitglme(S, 'success ~ group*day_c + day_c2 + (1|subject)', 'Distribution','Poisson')`; joint test on the interaction terms via `coefTest`.
- H2: from each GLME read the `[T.Electrode-Box-A2]` coefficient (vs B2 ref); IRR/OR = `exp(coef)`; one-sided p = `normcdf(coef/se)` (tests coef<0). Report `1/exp(coef)` as the B2 advantage.
- Per-animal: build per-subject summaries (mean success; pooled Σsuccess/Σtotal), then Welch via `ttest2(b,a,'Vartype','unequal')` and one-sided MWU via `ranksum(b,a,'tail','right')`. (Direction: B2 vs A2.)
- Classification (unmerged): for each unknown, contrast vs A2 and vs B2 (use `coefTest` with a contrast vector, or read coefficients + covariance from `R.<model>.CoefficientCovariance`), label A2-like/B2-like/ambiguous per METHODS.md.
- [ ] **Step 3: (Deferred) run tests once licensed.** Self-review the estimator calls and the per-animal math against the Python.
- [ ] **Step 4: Commit** `git commit -am "matlab(tdcs): GLMM + per-animal + classification models"`
---
### Task 4: Report, entry point, runners
**Files:**
- Create: `analysis/matlab/tdcs_report.m`, `analysis/matlab/tdcs_glm.m`, `analysis/matlab/run_all.m`, `analysis/matlab/run_tests.m`
- Create: `analysis/matlab/README.md`
**Interfaces:**
- Consumes all prior. Produces `tdcs_glm(scenario)` — the `switch/case` entry that validates the scenario name, calls `tdcs_scenario_data``tdcs_models``tdcs_report`, prints to console and writes `results/<scenario>.txt`.
**Steps:**
- [ ] **Step 1: Write `tdcs_report.m`** — given `S`, `R`, `meta`, `cfg`, print: (1) descriptives (per group: n_subj, n_sessions, mean success, pooled rate); (2) raw GLM — `disp(R.countGLME)` and `disp(R.rateGLME)` fixed-effects tables + the learning joint test; (3) interpretation mirroring `verdicts()`/`classify()` in the Python (H2 verdict with IRR/OR + one-sided p; per-animal p's; classification for unmerged; the standing caveats). Use `fprintf`; also capture to `results/<scenario>.txt` via `diary` or an output string.
- [ ] **Step 2: Write `tdcs_glm.m`** with an explicit `switch scenario` over the 6 names (each case is a self-contained call; `otherwise` errors with the valid list). Even though cases share the pipeline, enumerate all 6 so each is independently invocable and greppable, per the spec.
- [ ] **Step 3: Write `run_all.m`** (loop the 6 scenarios calling `tdcs_glm`) and `run_tests.m` (`results = runtests('analysis/matlab/tests'); disp(results);` with a nonzero exit on failure for `-batch`).
- [ ] **Step 4: Write `README.md`** — how to activate the license, run one scenario (`matlab -batch "tdcs_glm('mergeB2_full')"`), run all, run tests; note results were validated against the Python golden values and that GLMM ≠ GEE (interpretation matches, exact numbers differ).
- [ ] **Step 5: (Deferred) once licensed**, run `matlab -batch "run_tests"` and `matlab -batch "run_all"`, capture output, fix failures. Until then, self-review by reading.
- [ ] **Step 6: Commit** `git commit -am "matlab(tdcs): report, switch/case entry, runners, README"`
---
## Self-Review Notes
- **Spec coverage:** raw files in website format (Task 1 input, pre-generated) · parse+join+curated map (Task 1) · day windows + merge scenarios saved (Task 2) · `fitglme` count/rate/learning + per-animal + classification (Task 3) · switch/case per scenario + raw GLM + interpretation (Task 4) · `matlab.unittest` suite with golden values (Tasks 13). All covered.
- **Runtime caveat is pervasive:** every task defers actual execution to post-licensing; reviews are by reading + golden-value cross-checks, and no task may claim tests pass.
- **Golden values** are authoritative (computed from the Python this session). Per-animal stats must match to 3 decimals; GLME magnitudes are checked loosely because GLMM ≠ GEE.
- **Out of scope (per spec):** GEE/sandwich SEs, MATLAB plots, Bayesian MAP model, editing the Python or the DB.
@@ -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).
@@ -0,0 +1,86 @@
# Subject-Series Data Export — Design
**Date:** 2026-07-19
**Status:** Approved (design)
**Area:** `frontend/src/lib/dataExport.js`, `frontend/src/components/ExportDataModal.jsx`, `frontend/tests/`
**Supersedes:** the assignable-axes UI from `2026-07-19-configurable-data-export-design.md` (that branch's `buildMatrix`/`toCSV`/subject-group work is reused; its rows/columns/pinned modal is replaced).
## Problem
The export modal currently offers assignable row/column/pinned axes. That is more than needed. The user wants the export to mirror the **cross-subject metrics plot**: pick an **X field** and a **data value**, with each **subject as a column series**, empty where a subject has no data point, plus each subject's **group** included. The one capability missing today is choosing the X (row) field — it is fixed to calendar Date, whereas the plot's X-axis offers **# Days Reach** and daily fields.
## Layout (fixed)
- **Columns = subjects**, one series per subject, ordered clustered-by-group then by name (existing `listSubjectMembers`).
- **Rows = the chosen X field's values.**
- **Cells = the chosen Data value** for that (subject, X); empty (`''`) when absent. `0` and `''` remain real values (existing `hasValue`/`extractValue`).
- **Group row** gives each subject's group value (existing group-row support).
There is no pinned dimension and no assignable columns.
## The three pickers
1. **X (rows).** Options: `Date`, `# Days Reach`, and each **active** daily-template field. Default `Date` (keeps the same date-rows × subject-columns grid as today; only the line-1 label changes to `Data`, see CSV output).
2. **Data (cells).** Options: `listExportableParams(dailyTemplate, statuses)` — total, success rate, each count category, each daily field. Default: first parameter (`Total attempts`).
3. **Group by.** Options: `— None —`, `Name`, `Subject ID`, and each active `subject_info` field. Defaults to the experiment's saved group field (`localStorage['exp-subject-group-<id>']`), validated against the available options and coerced to `__none__` when unset or stale. Always visible (subjects are always the columns).
Preview line: `N rows × M subjects`. Export disabled when there are no data rows.
## X-field semantics
Each X coordinate maps a `(subject, X-value)` pair to a single daily status, from which the Data value is read:
- **`Date`** → rows are the distinct calendar dates present (`YYYY-MM-DD`), sorted. Cell reads the subject's status on that date. (Today's behavior.)
- **`# Days Reach`** → rows are `1, 2, 3, …` up to the maximum, per subject, of the count of that subject's recorded daily statuses. A cell for `(subject, N)` reads the subject's **Nth recorded day**, counting **all** recorded statuses sorted ascending by date (Day 1 = earliest record; days without an analysis summary are still counted — this intentionally differs from the plot, which counts analyzed sessions only).
- **daily field** (e.g. `Weight (g)`) → rows are the distinct **numeric** values that field takes across the data (`numericFieldVal`: builtin via `status[field.key]`, custom via `status.custom_fields[field.fieldId]`, `parseFloat`, non-numeric → excluded), sorted ascending. A cell for `(subject, v)` reads that subject's status where the field equals `v`; on ties, first status by date wins.
Row members that are entirely blank across all subjects are dropped (matching Date's existing behavior); subject columns are always kept even when empty.
## CSV output
Structure (X = # Days Reach, Data = Success rate, Group by = Treatment):
```
Data,Success rate
Group,Control,Control,Drug
# Days Reach,Mouse-A,Mouse-B,Mouse-C
1,0.5,0.6,0.7
2,0.55,,0.72
3,0.6,0.65,
```
- **Line 1** names the Data metric so the file is self-describing: two cells, `Data` and the metric label.
- **Blank line.**
- **Group row** (`Group` + each subject's group), only when Group by ≠ None; missing group value → `—`.
- **Header row:** the X-field label in the corner, then subject headers.
- **Data rows:** the X value, then one cell per subject.
LF line endings; every cell escaped via existing `escapeCsvCell`. Filename: existing `csvFilename(title, dataLabel)`.
## Implementation notes
Reuse from the current branch: `extractValue`, `listExportableParams`, `subjectGroupValue`, `listSubjectMembers`, `escapeCsvCell`, `toCSV` (its `{ context, corner, groupAxis, columns, rows }` shape is kept), and `csvFilename`.
Replace: the general assignable-axes `buildMatrix(config, ctx)` and its members/dispatch helpers (`otherDimension`, `dimLabel`, `EXPORT_DIMENSIONS`, `listDateMembers`, `listDimensionMembers`, `listParameterMembers`, `buildStatusIndex` in its current form) — remove what the fixed layout no longer uses rather than leaving dead code.
Add:
- `numericFieldVal(status, field)` — mirror of the plot's helper (or import/share it).
- `listSampleMembers(statuses, xField, dailyTemplate)` → the ordered row members for the chosen X coordinate (`{ id, label, ... }`).
- `buildSampleIndex(statuses, xField, dailyTemplate)``Map(animalId → Map(sampleValue → status))`, first-status-per-cell wins, encoding the date/#days/field resolution above.
- `buildSubjectSeriesMatrix({ xField, dataParam, groupField }, { statuses, animals, dailyTemplate })``{ context: { label: 'Data', value: dataParam.label }, corner: <X label>, groupAxis: grouping ? 'col' : null, columns: <subjects>, rows: [{ member, values }] }`, consumable by the existing `toCSV`.
Simplify `ExportDataModal.jsx` to the three pickers over these helpers; drop the Rows/Columns/Fixed controls.
## Testing
- `listSampleMembers` / `buildSampleIndex` for each X coordinate: Date (unchanged), # Days Reach (all-records ordinal, per-subject alignment), daily field (distinct numeric values sorted, non-numeric dropped, tie first-by-date).
- `buildSubjectSeriesMatrix`: subjects as columns clustered by group; empty cells where a subject lacks a point; blank row dropped; `groupAxis` null when Group by None.
- `toCSV` reused; add/keep a snapshot for the new context line + group row.
- `ExportDataModal`: renders three pickers; default X = Date produces date rows × subject columns with the chosen metric in cells; switching X to # Days Reach changes rows to ordinals; group row present when a valid group field is chosen; stale/unset group field coerces to None.
## Out of scope (YAGNI)
- Assignable columns / pinned dimension (removed).
- Choosing a subset of subjects (all subjects always included).
- Matching the plot's analyzed-only day counting (we count all recorded days).
- A footer or repeated context line.
@@ -0,0 +1,143 @@
# MATLAB port of the tDCS GLM analysis — Design
**Date:** 2026-07-20
**Status:** Approved (design)
**Location:** `analysis/matlab/`
**Ports:** the Python analysis in `analysis/` (`tdcs_glm.py`, `METHODS.md`) to MATLAB.
## Goal
Reproduce the tDCS reaching analysis in MATLAB: consume raw exports from the app,
derive the day-window × merge-scenario variations, run each scenario
independently via `switch/case`, and print the raw GLM output plus a
plain-language interpretation. Include a `matlab.unittest` test suite.
## Environment
- MATLAB **R2025b** + **Statistics and Machine Learning Toolbox** installed at
`~/matlab` (via `mpm`). Requires the user's license to be **activated** before
anything runs (`~/matlab/bin/activate_matlab.sh`, or a license file in
`~/matlab/licenses/`).
- Headless execution: `~/matlab/bin/matlab -batch "<cmd>"`. Tests run via
`matlab -batch "run_tests"`. **The MATLAB code and tests cannot be executed in
the dev environment until the license is active** — they are written first and
run once licensing is in place.
## Modeling engine
MATLAB has no GEE; the port uses **`fitglme`** (generalized linear mixed model)
with a **per-subject random intercept** as the single primary estimator — this
folds the Python GEE + random-intercept models into one native tool. The Python
mixed model already agreed with its GEE, so results should track. Reference group
is **Electrode-Box-B2**; `day_c` is the mean-centered training day and
`day_c2 = day_c^2` captures the plateau.
Models per scenario (on the scenario's data table):
- **Count:** `success ~ group + day_c + day_c2 + (1|subject)`, `Distribution='Poisson'` → IRRs vs B2.
- **Rate:** `success ~ group + day_c + day_c2 + (1|subject)`, `Distribution='Binomial'`, `BinomialSize=total` → odds ratios. Zero-attempt sessions (`total==0`, undefined rate) are dropped for this model only; the count model keeps them (they are genuine 0-success observations).
- **Learning rate:** `success ~ group*day_c + day_c2 + (1|subject)`, Poisson → group×day slope differences + a joint `coefTest`.
- **Per-animal:** collapse each animal to one number (mean count; pooled rate = Σsuccess/Σtotal), then `ttest2` (Welch) and `ranksum` (Mann-Whitney, one-sided) — the honest small-n check.
- **`unmerged_*` scenarios only:** classify Box-A and Right-Electrode against **both** anchors via `coefTest` contrasts → A2-like / B2-like / ambiguous.
## Data flow
```
raw/raw_success.csv ┐
raw/raw_total.csv ┴─ tdcs_load_data → long table {subject,group,day,success,total}
→ tdcs_scenario_data(scenario)
→ filtered+merged table (saved: derived/<scenario>.csv)
→ fitglme models → console + results/<scenario>.txt
```
### Raw files (produced now, in the website's export format)
Two app-style matrices, identical to the app's **Export Data** output with
X = "# Days Reach", Group by = "Group":
- `raw/raw_success.csv` — Data = Success
- `raw/raw_total.csv` — Data = Total attempts
Format (both):
```
Data,<metric>
<blank>
Group,<group per subject column>
# Days Reach,<subject headers>
<day>,<value per subject>
...
```
These are generated from the running app (via the same export logic) and are
byte-for-byte what the UI would export; the user can regenerate them from the UI.
### `tdcs_load_data.m`
Parses the matrix format (skips the `Data,` line and blank line, reads the
`Group` row, the `# Days Reach` + subject-header row, then the day rows),
reshapes both matrices to long, and joins on (subject, day) to produce one table
with `subject, group, day, success, total`. Drops blank cells; applies day ≥ 0.
Returns the full long table (all 5 groups, Box-A included).
## Scenarios (`switch/case`, 6 self-contained cases)
`tdcs_glm(scenario)` dispatches on the scenario name; each case runs end-to-end
and can be run alone. Cases:
| case | merge | window |
|---|---|---|
| `unmerged_full` | 5 groups (classify Box-A & Right) | days 026 |
| `unmerged_d0_10` | 5 groups | days 010 |
| `mergeA2_full` | Box-A→A2, Right→B2 | 026 |
| `mergeA2_d0_10` | Box-A→A2, Right→B2 | 010 |
| `mergeB2_full` | Box-A→B2, Right→B2 | 026 |
| `mergeB2_d0_10` | Box-A→B2, Right→B2 | 010 |
Merge maps: `mergeA2 = {Electrode-Box-A→Electrode-Box-A2, Right-Electrode→Electrode-Box-B2}`;
`mergeB2 = {Electrode-Box-A→Electrode-Box-B2, Right-Electrode→Electrode-Box-B2}`.
Each case saves its derived scenario table to `derived/<scenario>.csv`.
`run_all.m` loops all six; `run_tests.m` runs the test suite.
## Printed output per scenario
To console and `results/<scenario>.txt`:
1. **Descriptives** — per group: n_subjects, n_sessions, mean success count, pooled rate.
2. **Raw GLM** — the literal `fitglme` fixed-effects table (Estimate, SE, tStat, pValue, CI) for count and rate models, plus the learning-rate joint test statistic/p.
3. **Interpretation** — plain language mirroring the Python verdicts: H2 (B2 vs A2) as IRR/OR + one-sided p + SUPPORTED/not; per-animal p-values; classification (unmerged only); and the standing caveats (n=34/group, single-subject fragility, count vs rate).
## Files
```
analysis/matlab/
raw/raw_success.csv, raw/raw_total.csv # generated inputs (website format)
tdcs_load_data.m # parse + reshape + join → long table
tdcs_scenario_data.m # apply merge map + day window; save derived
tdcs_models.m # fit GLMMs, per-animal tests, classification, contrasts
tdcs_report.m # format descriptives + raw GLM + interpretation
tdcs_glm.m # switch/case entry over the 6 scenarios
run_all.m, run_tests.m
derived/ results/ # outputs (created at runtime)
tests/
tLoadData.m # join correctness (verified cells), day filter, zero-attempt drop
tScenarioData.m # per-scenario groups/subject counts, window caps
tModels.m # golden-value/direction checks vs the Python numbers (loose tolerance)
```
## Testing
`matlab.unittest`, run headless via `matlab -batch "run_tests"`:
- **Data:** `tdcs_load_data` join correct — verified cells (e.g. Vu-vuong day0 success=18, total=61; Banh-mi-1 day0 success=13) and per-group subject counts; day≥0 and zero-attempt handling.
- **Scenario derivation:** each scenario yields the right groups/subjects (`mergeB2` → B2=5 subjects, A2=3; `mergeA2` → 4/4/4; `d0_10` caps at day 10) and saves `derived/<scenario>.csv`.
- **Model sanity / golden values:** GLM results land near the Python numbers within a loose tolerance and match direction — e.g. `mergeB2_full` rate H2 odds ≈ 1.41.6 with B2>A2; anchor H2 supported on rate; `unmerged` classification labels. GLMM ≠ GEE exactly, so magnitude tolerances are wide but sign/ordering checks are strict.
Executed once the license is active; written but unrun until then (flagged in the report).
## Out of scope (YAGNI)
- Re-implementing GEE / cluster-robust sandwich SEs (using native `fitglme` instead).
- The learning-curve plots (the Python `.png`s remain; not re-created in MATLAB unless asked).
- Changing the Python analysis (it stays as the reference).
- Bayesian mixed model (the Python MAP sensitivity has no direct `fitglme` analog and is dropped; the GLMM *is* the random-intercept model).
+587 -13
View File
@@ -12,7 +12,8 @@
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^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": { "devDependencies": {
"@babel/core": "^7.24.7", "@babel/core": "^7.24.7",
@@ -1757,7 +1758,6 @@
"version": "7.29.2", "version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"dev": true,
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
@@ -2832,6 +2832,60 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
},
"node_modules/@types/graceful-fs": { "node_modules/@types/graceful-fs": {
"version": "4.1.9", "version": "4.1.9",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
@@ -3598,6 +3652,14 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"engines": {
"node": ">=6"
}
},
"node_modules/co": { "node_modules/co": {
"version": "4.6.0", "version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -3739,9 +3801,117 @@
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
"dev": true, },
"peer": true "node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"engines": {
"node": ">=12"
}
}, },
"node_modules/date-fns": { "node_modules/date-fns": {
"version": "3.6.0", "version": "3.6.0",
@@ -3775,6 +3945,11 @@
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true "dev": true
}, },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
},
"node_modules/dedent": { "node_modules/dedent": {
"version": "1.7.2", "version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@@ -3917,6 +4092,15 @@
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true "dev": true
}, },
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/domexception": { "node_modules/domexception": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
@@ -4156,6 +4340,11 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
},
"node_modules/exit": { "node_modules/exit": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
@@ -4181,6 +4370,14 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0" "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
} }
}, },
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-glob": { "node_modules/fast-glob": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -4642,6 +4839,14 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"engines": {
"node": ">=12"
}
},
"node_modules/is-arguments": { "node_modules/is-arguments": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
@@ -6282,6 +6487,11 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="
},
"node_modules/lodash.debounce": { "node_modules/lodash.debounce": {
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
@@ -6501,7 +6711,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -6950,6 +7159,21 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/proxy-from-env": { "node_modules/proxy-from-env": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -7089,6 +7313,35 @@
"react-dom": ">=16.8" "react-dom": ">=16.8"
} }
}, },
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
"dependencies": {
"fast-equals": "^5.0.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/read-cache": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -7110,6 +7363,41 @@
"node": ">=8.10.0" "node": ">=8.10.0"
} }
}, },
"node_modules/recharts": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts-scale": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
"dependencies": {
"decimal.js-light": "^2.4.1"
}
},
"node_modules/recharts/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
},
"node_modules/redent": { "node_modules/redent": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -7758,6 +8046,11 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.16", "version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -7969,6 +8262,27 @@
"node": ">=10.12.0" "node": ">=10.12.0"
} }
}, },
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "4.5.14", "version": "4.5.14",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz",
@@ -9407,8 +9721,7 @@
"@babel/runtime": { "@babel/runtime": {
"version": "7.29.2", "version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="
"dev": true
}, },
"@babel/template": { "@babel/template": {
"version": "7.28.6", "version": "7.28.6",
@@ -10153,6 +10466,60 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="
},
"@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
},
"@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
},
"@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"requires": {
"@types/d3-color": "*"
}
},
"@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="
},
"@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"requires": {
"@types/d3-time": "*"
}
},
"@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"requires": {
"@types/d3-path": "*"
}
},
"@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
},
"@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
},
"@types/graceful-fs": { "@types/graceful-fs": {
"version": "4.1.9", "version": "4.1.9",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
@@ -10711,6 +11078,11 @@
"wrap-ansi": "^7.0.0" "wrap-ansi": "^7.0.0"
} }
}, },
"clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="
},
"co": { "co": {
"version": "4.6.0", "version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -10820,9 +11192,84 @@
"csstype": { "csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
"dev": true, },
"peer": true "d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"requires": {
"internmap": "1 - 2"
}
},
"d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="
},
"d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="
},
"d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="
},
"d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"requires": {
"d3-color": "1 - 3"
}
},
"d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="
},
"d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"requires": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
}
},
"d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"requires": {
"d3-path": "^3.1.0"
}
},
"d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"requires": {
"d3-array": "2 - 3"
}
},
"d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"requires": {
"d3-time": "1 - 3"
}
},
"d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="
}, },
"date-fns": { "date-fns": {
"version": "3.6.0", "version": "3.6.0",
@@ -10844,6 +11291,11 @@
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true "dev": true
}, },
"decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
},
"dedent": { "dedent": {
"version": "1.7.2", "version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@@ -10946,6 +11398,15 @@
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true "dev": true
}, },
"dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"requires": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"domexception": { "domexception": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
@@ -11116,6 +11577,11 @@
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"dev": true "dev": true
}, },
"eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
},
"exit": { "exit": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
@@ -11135,6 +11601,11 @@
"jest-util": "^29.7.0" "jest-util": "^29.7.0"
} }
}, },
"fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="
},
"fast-glob": { "fast-glob": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -11457,6 +11928,11 @@
"side-channel": "^1.1.0" "side-channel": "^1.1.0"
} }
}, },
"internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="
},
"is-arguments": { "is-arguments": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
@@ -12669,6 +13145,11 @@
"p-locate": "^4.1.0" "p-locate": "^4.1.0"
} }
}, },
"lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="
},
"lodash.debounce": { "lodash.debounce": {
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
@@ -12835,8 +13316,7 @@
"object-assign": { "object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="
"dev": true
}, },
"object-hash": { "object-hash": {
"version": "3.0.0", "version": "3.0.0",
@@ -13101,6 +13581,23 @@
"sisteransi": "^1.0.5" "sisteransi": "^1.0.5"
} }
}, },
"prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"requires": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
},
"dependencies": {
"react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
}
}
},
"proxy-from-env": { "proxy-from-env": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -13185,6 +13682,27 @@
"react-router": "6.30.3" "react-router": "6.30.3"
} }
}, },
"react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
"requires": {
"fast-equals": "^5.0.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
}
},
"react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"requires": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
}
},
"read-cache": { "read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -13203,6 +13721,36 @@
"picomatch": "^2.2.1" "picomatch": "^2.2.1"
} }
}, },
"recharts": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
"requires": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
},
"dependencies": {
"react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
}
}
},
"recharts-scale": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
"requires": {
"decimal.js-light": "^2.4.1"
}
},
"redent": { "redent": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -13674,6 +14222,11 @@
"thenify": ">= 3.1.0 < 4" "thenify": ">= 3.1.0 < 4"
} }
}, },
"tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
},
"tinyglobby": { "tinyglobby": {
"version": "0.2.16", "version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -13815,6 +14368,27 @@
"convert-source-map": "^2.0.0" "convert-source-map": "^2.0.0"
} }
}, },
"victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"requires": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"vite": { "vite": {
"version": "4.5.14", "version": "4.5.14",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz",
+10 -5
View File
@@ -6,8 +6,8 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "jest --runInBand --forceExit", "test": "node --max-old-space-size=8192 node_modules/.bin/jest --runInBand --forceExit",
"test:coverage": "jest --runInBand --forceExit --coverage", "test:coverage": "node --max-old-space-size=8192 node_modules/.bin/jest --runInBand --forceExit --coverage",
"test:e2e": "playwright test" "test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
@@ -15,7 +15,8 @@
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^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": { "devDependencies": {
"@babel/core": "^7.24.7", "@babel/core": "^7.24.7",
@@ -37,8 +38,12 @@
}, },
"jest": { "jest": {
"testEnvironment": "jsdom", "testEnvironment": "jsdom",
"setupFilesAfterEnv": ["<rootDir>/tests/setup.js"], "setupFilesAfterEnv": [
"testMatch": ["**/tests/**/*.test.{js,jsx}"], "<rootDir>/tests/setup.js"
],
"testMatch": [
"**/tests/**/*.test.{js,jsx}"
],
"transform": { "transform": {
"^.+\\.[jt]sx?$": "babel-jest" "^.+\\.[jt]sx?$": "babel-jest"
}, },
+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 { Routes, Route, Link, useLocation } from 'react-router-dom';
import { systemApi } from './api/client';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import ExperimentDetail from './pages/ExperimentDetail'; import ExperimentDetail from './pages/ExperimentDetail';
import AnimalDetail from './pages/AnimalDetail'; import AnimalDetail from './pages/AnimalDetail';
import DailyStatusDetail from './pages/DailyStatusDetail';
import ExperimentDayView from './pages/ExperimentDayView';
function Navbar() { function Navbar() {
return ( 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() { export default function App() {
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50 flex flex-col">
<Navbar /> <Navbar />
<main> <main className="flex-1">
<Routes> <Routes>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/experiments/:id" element={<ExperimentDetail />} /> <Route path="/experiments/:id" element={<ExperimentDetail />} />
<Route path="/experiments/:id/day/:date" element={<ExperimentDayView />} />
<Route path="/animals/:id" element={<AnimalDetail />} /> <Route path="/animals/:id" element={<AnimalDetail />} />
<Route path="/daily-statuses/:id" element={<DailyStatusDetail />} />
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>
</main> </main>
<Footer />
</div> </div>
); );
} }
+33
View File
@@ -31,6 +31,16 @@ export const experimentsApi = {
delete: (id) => api.delete(`/experiments/${id}`), delete: (id) => api.delete(`/experiments/${id}`),
getTemplate: (id) => api.get(`/experiments/${id}/template`).then((r) => r.data), getTemplate: (id) => api.get(`/experiments/${id}/template`).then((r) => r.data),
updateTemplate: (id, template) => api.put(`/experiments/${id}/template`, 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 ─────────────────────────────────────────────────────────────────── // ── Animals ───────────────────────────────────────────────────────────────────
@@ -41,6 +51,8 @@ export const animalsApi = {
create: (data) => api.post('/animals', data).then((r) => r.data), create: (data) => api.post('/animals', data).then((r) => r.data),
update: (id, data) => api.put(`/animals/${id}`, data).then((r) => r.data), update: (id, data) => api.put(`/animals/${id}`, data).then((r) => r.data),
delete: (id) => api.delete(`/animals/${id}`), delete: (id) => api.delete(`/animals/${id}`),
updateCsvConfig: (id, config) =>
api.patch(`/animals/${id}/csv-config`, config).then((r) => r.data),
}; };
// ── Daily Statuses ───────────────────────────────────────────────────────────── // ── Daily Statuses ─────────────────────────────────────────────────────────────
@@ -51,9 +63,30 @@ export const dailyStatusesApi = {
create: (data) => api.post('/daily-statuses', data).then((r) => r.data), create: (data) => api.post('/daily-statuses', data).then((r) => r.data),
update: (id, data) => api.put(`/daily-statuses/${id}`, 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}`), delete: (id) => api.delete(`/daily-statuses/${id}`),
updateAnalysisSummary: (id, data) => api.put(`/daily-statuses/${id}/analysis-summary`, data).then((r) => r.data),
}; };
// ── Audit Logs ──────────────────────────────────────────────────────────────── // ── Audit Logs ────────────────────────────────────────────────────────────────
export const auditLogsApi = { export const auditLogsApi = {
list: (params) => api.get('/audit-logs', { params }).then((r) => r.data), 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', 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 [logs, setLogs] = useState([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -46,14 +46,14 @@ export default function AuditLogSection({ tableName, recordId }) {
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
auditLogsApi auditLogsApi
.list({ tableName, recordId, limit: 50 }) .list({ tableName, recordId, limit })
.then(({ logs, total }) => { .then(({ logs, total }) => {
setLogs(logs); setLogs(logs);
setTotal(total); setTotal(total);
}) })
.catch((e) => setError(e.message)) .catch((e) => setError(e.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [tableName, recordId]); }, [tableName, recordId, limit]);
return ( return (
<section aria-label="Modification History" className="mt-8"> <section aria-label="Modification History" className="mt-8">
@@ -76,7 +76,7 @@ export default function AuditLogSection({ tableName, recordId }) {
{logs.length > 0 && ( {logs.length > 0 && (
<div className="border border-gray-200 rounded-lg overflow-hidden divide-y divide-gray-100"> <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"> <div key={log.id} className="bg-white">
<button <button
className="w-full text-left px-4 py-3 flex items-center gap-3 hover:bg-gray-50 transition-colors" 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> </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> </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>
);
}
+142 -47
View File
@@ -1,27 +1,42 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import Button from './ui/Button'; import Button from './ui/Button';
import FormField, { Input, Textarea } from './ui/FormField'; import { Input, Textarea } from './ui/FormField';
import Alert from './ui/Alert'; import Alert from './ui/Alert';
import { dailyStatusesApi } from '../api/client'; import { dailyStatusesApi, experimentsApi } from '../api/client';
const BUILTIN_KEYS = new Set(['experiment_description', 'vitals', 'treatment', 'notes']); const BUILTIN_KEYS = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
const EMPTY_TEMPLATE = [];
/** Extract the value for a field from a status record */ // 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) { function getValue(status, field) {
if (!status) return ''; if (!status) return '';
if (field.builtin) return status[field.key] ?? ''; if (field.builtin) return status[field.key] ?? '';
return status.custom_fields?.[field.key] ?? ''; return status.custom_fields?.[field.fieldId] ?? '';
} }
export default function DailyStatusForm({ existing, animalId, template = [], onSuccess, onCancel }) { 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 today = format(new Date(), 'yyyy-MM-dd');
// Active fields from template (date is always first and handled separately) // Active fields from template (date is always first and handled separately)
const activeFields = template.filter((f) => f.active); const activeFields = template.filter((f) => f.active);
function buildInitialValues() { function buildInitialValues() {
const vals = { date: existing ? format(new Date(existing.date), 'yyyy-MM-dd') : today }; const vals = { date: existing ? String(existing.date).slice(0, 10) : today };
for (const f of activeFields) { for (const f of activeFields) {
vals[f.key] = getValue(existing, f); vals[f.key] = getValue(existing, f);
} }
@@ -32,6 +47,7 @@ export default function DailyStatusForm({ existing, animalId, template = [], onS
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const [apiError, setApiError] = useState(null); const [apiError, setApiError] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [savingTagFor, setSavingTagFor] = useState(null); // fieldId currently being saved
useEffect(() => { useEffect(() => {
setValues(buildInitialValues()); setValues(buildInitialValues());
@@ -41,6 +57,25 @@ export default function DailyStatusForm({ existing, animalId, template = [], onS
const set = (key) => (e) => setValues((v) => ({ ...v, [key]: 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() { function validate() {
const e = {}; const e = {};
if (!values.date) { if (!values.date) {
@@ -52,33 +87,46 @@ export default function DailyStatusForm({ existing, animalId, template = [], onS
return Object.keys(e).length === 0; 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) { async function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
if (!validate()) return; if (!validate()) return;
setLoading(true); setLoading(true);
setApiError(null); setApiError(null);
try { try {
// Separate builtin values from custom_fields onSuccess(await doSave());
const builtin = {}; } catch (err) {
const custom = {}; setApiError(err);
for (const f of activeFields) { } finally {
const val = values[f.key] || null; setLoading(false);
if (f.builtin) builtin[f.key] = val; }
else custom[f.key] = val;
} }
// Merge with any pre-existing custom_fields not in current active template async function handleNavigate(direction) {
// (so we don't wipe data for fields that were hidden after the entry was made) if (!validate()) return;
const existingCustom = existing?.custom_fields ?? {}; setLoading(true);
const mergedCustom = { ...existingCustom, ...custom }; setApiError(null);
try {
const payload = { date: values.date, ...builtin, custom_fields: mergedCustom }; const result = await doSave();
onNavigate(direction, result);
const result = existing
? await dailyStatusesApi.update(existing.id, payload)
: await dailyStatusesApi.create({ ...payload, animal_id: animalId });
onSuccess(result);
} catch (err) { } catch (err) {
setApiError(err); setApiError(err);
} finally { } finally {
@@ -87,36 +135,62 @@ export default function DailyStatusForm({ existing, animalId, template = [], onS
} }
return ( return (
<form onSubmit={handleSubmit} noValidate className="space-y-4"> <form onSubmit={handleSubmit} noValidate>
{apiError && ( {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)} />
)} )}
{/* Date — always present */} <div className="grid grid-cols-12 gap-x-3 gap-y-2 mb-4">
<FormField label="Date" name="date" error={errors.date} required> {/* 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} /> <Input type="date" name="date" value={values.date} onChange={set('date')} required disabled={loading} />
</FormField> {errors.date && <p role="alert" className="text-xs text-red-500 mt-0.5">{errors.date}</p>}
</div>
</div>
{/* Dynamic fields from template */} {/* Dynamic fields */}
{activeFields.map((field) => ( {activeFields.map((field) => {
<FormField key={field.key} label={field.label} name={field.key}> const isTextarea = field.type === 'textarea';
{field.type === 'textarea' ? ( const span = isTextarea ? 12 : widthToSpan(field.width ?? 100);
<Textarea const fieldTags = field.tags ?? [];
name={field.key} const currentValue = (values[field.key] ?? '').trim();
value={values[field.key] ?? ''} const canSaveTag = experimentId && onTemplateUpdate && currentValue && !fieldTags.includes(currentValue) && !field.tagsDisabled;
onChange={set(field.key)} return (
disabled={loading} <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 <Input name={field.key} value={values[field.key] ?? ''} onChange={set(field.key)} disabled={loading} />
name={field.key}
value={values[field.key] ?? ''}
onChange={set(field.key)}
disabled={loading}
/>
)} )}
</FormField> {(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 && ( {activeFields.length === 0 && (
<p className="text-sm text-gray-400 italic text-center py-2"> <p className="text-sm text-gray-400 italic text-center py-2">
@@ -124,10 +198,31 @@ export default function DailyStatusForm({ existing, animalId, template = [], onS
</p> </p>
)} )}
<div className="flex justify-end gap-3 pt-2"> <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 variant="secondary" type="button" onClick={onCancel} disabled={loading}>Cancel</Button>
<Button type="submit" loading={loading}>{existing ? 'Save Changes' : 'Log Status'}</Button> <Button type="submit" loading={loading}>{existing ? 'Save Changes' : 'Log Status'}</Button>
</div> </div>
</div>
</form> </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>
);
}
+140
View File
@@ -0,0 +1,140 @@
import React, { useEffect, useMemo, useState } from 'react';
import { experimentsApi } from '../api/client';
import Button from './ui/Button';
import Alert from './ui/Alert';
import { listExportableParams, buildSubjectSeriesMatrix, toCSV, csvFilename } from '../lib/dataExport';
const PARAM_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, subjectTemplate = [], groupField = '__none__', onClose,
}) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [statuses, setStatuses] = useState([]);
const [xField, setXField] = useState('__date__');
const [dataId, setDataId] = useState(null);
const [groupBy, setGroupBy] = useState(() => {
const valid = groupField === '__none__' || groupField === '__name__' || groupField === '__id__'
|| subjectTemplate.some((f) => f.active && f.fieldId === groupField);
return valid ? 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 params = useMemo(() => listExportableParams(dailyTemplate ?? [], statuses), [dailyTemplate, statuses]);
const effDataId = dataId ?? params[0]?.id ?? null;
const dataParam = params.find((p) => p.id === effDataId) ?? params[0] ?? null;
const activeDaily = useMemo(() => (dailyTemplate ?? []).filter((f) => f.active), [dailyTemplate]);
const xOptions = useMemo(() => [
{ id: '__date__', label: 'Date' },
{ id: '__days__', label: '# Days Reach (ordinal)' },
...activeDaily.map((f) => ({ id: f.fieldId, label: f.label })),
], [activeDaily]);
const matrix = useMemo(
() => (dataParam
? buildSubjectSeriesMatrix({ xField, dataParam, groupField: groupBy }, { statuses, animals, dailyTemplate })
: { columns: [], rows: [], context: { label: 'Data', value: '' }, corner: '', groupAxis: null }),
[xField, dataParam, groupBy, statuses, animals, dailyTemplate],
);
const hasData = matrix.rows.length > 0;
const groupedParams = 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 || !dataParam) return;
triggerDownload(csvFilename(experimentTitle, dataParam.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';
return (
<div className="space-y-4">
<p className="text-sm text-gray-500">
One value per subject over an X axis each column is a subject, each row an X value. Blank where a subject has no value.
</p>
<div>
<label htmlFor="export-x" className="block text-xs font-medium text-gray-500 mb-1">X axis (rows)</label>
<select id="export-x" value={xField} onChange={(e) => setXField(e.target.value)} className={selectCls}>
{xOptions.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
</div>
<div>
<label htmlFor="export-data" className="block text-xs font-medium text-gray-500 mb-1">Data (cells)</label>
<select id="export-data" value={effDataId ?? ''} onChange={(e) => setDataId(e.target.value)} className={selectCls}>
{groupedParams.map((g) => (
<optgroup key={g.group} label={PARAM_GROUP_LABELS[g.group] ?? g.group}>
{g.items.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
</optgroup>
))}
</select>
</div>
<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} row${matrix.rows.length !== 1 ? 's' : ''} × ${matrix.columns.length} subject${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>
);
}
+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>
);
}
+287 -47
View File
@@ -1,21 +1,111 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { experimentsApi } from '../api/client'; import { experimentsApi } from '../api/client';
import Button from './ui/Button'; import Button from './ui/Button';
import Alert from './ui/Alert'; import Alert from './ui/Alert';
// Slugify a label into a valid field key // ── helpers ────────────────────────────────────────────────────────────────────
function toKey(label) { function toKey(label) {
return label return label.toLowerCase().trim()
.toLowerCase() .replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 64);
.trim()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64);
} }
function FieldRow({ field, onChange, onToggle, onRemove, allKeys }) { // ── 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 [labelDraft, setLabelDraft] = useState(field.label);
const [labelError, setLabelError] = useState(''); const [labelError, setLabelError] = useState('');
const [showTags, setShowTags] = useState(false);
useEffect(() => { setLabelDraft(field.label); }, [field.label]); useEffect(() => { setLabelDraft(field.label); }, [field.label]);
@@ -23,23 +113,36 @@ function FieldRow({ field, onChange, onToggle, onRemove, allKeys }) {
const trimmed = labelDraft.trim(); const trimmed = labelDraft.trim();
if (!trimmed) { setLabelError('Label cannot be empty'); return; } if (!trimmed) { setLabelError('Label cannot be empty'); return; }
if (trimmed.length > 128) { setLabelError('Label must be ≤128 characters'); return; } if (trimmed.length > 128) { setLabelError('Label must be ≤128 characters'); return; }
// For custom fields, also ensure the derived key is unique
if (!field.builtin) { if (!field.builtin) {
const newKey = toKey(trimmed); const newKey = toKey(trimmed);
if (!newKey) { setLabelError('Label must contain at least one alphanumeric character'); return; } if (!newKey) { setLabelError('Label must contain at least one alphanumeric character'); return; }
const duplicate = allKeys.find((k) => k !== field.key && k === newKey); if (allKeys.find((k) => k !== field.key && k === newKey)) {
if (duplicate) { setLabelError('A field with this name already exists'); return; } setLabelError('A field with this name already exists'); return;
}
} }
setLabelError(''); setLabelError('');
onChange({ ...field, label: trimmed, key: field.builtin ? field.key : toKey(trimmed) }); onChange({ ...field, label: trimmed, key: field.builtin ? field.key : toKey(trimmed) });
} }
const tagCount = (field.tags ?? []).length;
return ( return (
<div className={`flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors ${field.active ? 'bg-white border-gray-200' : 'bg-gray-50 border-gray-100 opacity-60'}`}> <div
{/* Drag handle placeholder (visual only) */} 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'}`}
<span className="text-gray-300 cursor-default select-none text-lg leading-none"></span> 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 */} {/* Label input */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -53,10 +156,17 @@ function FieldRow({ field, onChange, onToggle, onRemove, allKeys }) {
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitLabel(); } }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitLabel(); } }}
/> />
{labelError && <p className="text-xs text-red-500 mt-0.5">{labelError}</p>} {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}</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> </div>
{/* Type badge */} {/* Type selector */}
<select <select
aria-label={`Field type for ${field.key}`} 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" 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"
@@ -68,11 +178,78 @@ function FieldRow({ field, onChange, onToggle, onRemove, allKeys }) {
<option value="textarea">Long text</option> <option value="textarea">Long text</option>
</select> </select>
{/* Toggle active */} {/* 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 <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'} title={field.active ? 'Hide field' : 'Show field'}
aria-label={field.active ? `Hide field ${field.label}` : `Show field ${field.label}`} aria-label={field.active ? `Hide ${field.label}` : `Show ${field.label}`}
onClick={() => onToggle(field.key)} onClick={() => onToggle(field.fieldId)}
className={`text-sm px-2 py-1 rounded border transition-colors ${ className={`text-sm px-2 py-1 rounded border transition-colors ${
field.active field.active
? 'border-gray-200 text-gray-500 hover:bg-yellow-50 hover:border-yellow-300 hover:text-yellow-700' ? 'border-gray-200 text-gray-500 hover:bg-yellow-50 hover:border-yellow-300 hover:text-yellow-700'
@@ -82,49 +259,102 @@ function FieldRow({ field, onChange, onToggle, onRemove, allKeys }) {
{field.active ? 'Hide' : 'Show'} {field.active ? 'Hide' : 'Show'}
</button> </button>
{/* Remove — only for custom fields */} {/* Remove (custom fields only) */}
{!field.builtin && ( {!field.builtin && (
<button <button
type="button"
title="Remove field permanently" title="Remove field permanently"
aria-label={`Remove field ${field.label}`} aria-label={`Remove ${field.label}`}
onClick={() => onRemove(field.key)} 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" className="text-sm px-2 py-1 rounded border border-red-200 text-red-500 hover:bg-red-50 transition-colors"
> ></button>
)}
</button> </div>
{/* Tag panel (collapsible) */}
{showTags && (
<TagPanel field={field} onChange={onChange} onAddTag={onAddTag} onRemoveTag={onRemoveTag} />
)} )}
</div> </div>
); );
} }
export default function TemplateEditor({ experimentId, onClose }) { // ── Main TemplateEditor ────────────────────────────────────────────────────────
export default function TemplateEditor({ experimentId, onClose, loadFn, saveFn }) {
const [fields, setFields] = useState([]); const [fields, setFields] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
// Add-field form
const [newLabel, setNewLabel] = useState(''); const [newLabel, setNewLabel] = useState('');
const [newType, setNewType] = useState('text'); const [newType, setNewType] = useState('text');
const [addError, setAddError] = useState(''); const [addError, setAddError] = useState('');
// Drag state
const dragIndex = useRef(null);
const [dragOverIndex, setDragOverIndex] = useState(null);
useEffect(() => { useEffect(() => {
experimentsApi.getTemplate(experimentId) const fn = loadFn ?? (() => experimentsApi.getTemplate(experimentId));
.then(setFields) fn().then(setFields).catch((e) => setError(e.message)).finally(() => setLoading(false));
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [experimentId]); }, [experimentId]);
// ── field mutation helpers ─────────────────────────────────────────────────
function updateField(updated) { function updateField(updated) {
setFields((prev) => prev.map((f) => f.key === updated.key ? updated : f)); 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));
} }
function toggleField(key) { // ── tag helpers ────────────────────────────────────────────────────────────
setFields((prev) => prev.map((f) => f.key === key ? { ...f, active: !f.active } : f));
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
));
} }
function removeField(key) { // ── drag handlers ──────────────────────────────────────────────────────────
setFields((prev) => prev.filter((f) => f.key !== key));
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() { function addField() {
const trimmed = newLabel.trim(); const trimmed = newLabel.trim();
@@ -133,18 +363,22 @@ export default function TemplateEditor({ experimentId, onClose }) {
if (!key) { setAddError('Name must contain at least one alphanumeric character'); return; } 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; } if (fields.some((f) => f.key === key)) { setAddError(`A field with key "${key}" already exists`); return; }
setAddError(''); setAddError('');
setFields((prev) => [...prev, { key, label: trimmed, type: newType, builtin: false, active: true }]); const fieldId = crypto.randomUUID();
setFields((prev) => [...prev, { fieldId, key, label: trimmed, type: newType, builtin: false, active: true, tags: [] }]);
setNewLabel(''); setNewLabel('');
setNewType('text'); setNewType('text');
} }
// ── save ───────────────────────────────────────────────────────────────────
async function save() { async function save() {
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
await experimentsApi.updateTemplate(experimentId, fields); const fn = saveFn ?? ((f) => experimentsApi.updateTemplate(experimentId, f));
await fn(fields);
setSuccess(true); setSuccess(true);
setTimeout(() => { setSuccess(false); onClose(fields); }, 800); setTimeout(() => { setSuccess(false); onClose(fields); }, 700);
} catch (e) { } catch (e) {
setError(e.message); setError(e.message);
} finally { } finally {
@@ -167,14 +401,22 @@ export default function TemplateEditor({ experimentId, onClose }) {
{fields.length === 0 && ( {fields.length === 0 && (
<p className="text-sm text-gray-400 italic text-center py-4">No fields yet. Add one below.</p> <p className="text-sm text-gray-400 italic text-center py-4">No fields yet. Add one below.</p>
)} )}
{fields.map((field) => ( {fields.map((field, index) => (
<FieldRow <FieldRow
key={field.key} key={field.fieldId}
field={field} field={field}
index={index}
total={fields.length}
onChange={updateField} onChange={updateField}
onToggle={toggleField} onToggle={toggleField}
onRemove={removeField} onRemove={removeField}
onAddTag={addTag}
onRemoveTag={removeTag}
allKeys={allKeys} allKeys={allKeys}
onDragStart={handleDragStart}
onDragEnter={handleDragEnter}
onDragEnd={handleDragEnd}
isDragOver={dragOverIndex === index}
/> />
))} ))}
</div> </div>
@@ -203,14 +445,12 @@ export default function TemplateEditor({ experimentId, onClose }) {
<option value="text">Short text</option> <option value="text">Short text</option>
<option value="textarea">Long text</option> <option value="textarea">Long text</option>
</select> </select>
<Button size="sm" onClick={addField} type="button">+ Add</Button> <Button size="sm" type="button" onClick={addField}>+ Add</Button>
</div> </div>
</div> </div>
{/* Info note */}
<p className="text-xs text-gray-400"> <p className="text-xs text-gray-400">
<strong>Hide</strong> removes a builtin field from forms while preserving its data. 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
<strong className="ml-1"></strong> permanently removes a custom field (data in existing records is not deleted).
</p> </p>
{/* Actions */} {/* Actions */}
+25 -4
View File
@@ -1,16 +1,37 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import Modal from './Modal'; import Modal from './Modal';
import Button from './Button'; 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 ( return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm"> <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"> <div className="flex justify-end gap-3">
<Button variant="secondary" onClick={onClose} disabled={loading}> <Button variant="secondary" onClick={onClose} disabled={loading}>
Cancel Cancel
</Button> </Button>
<Button variant="danger" onClick={onConfirm} loading={loading}> <Button variant="danger" onClick={onConfirm} loading={loading} disabled={!ready}>
Delete Delete
</Button> </Button>
</div> </div>
+1 -1
View File
@@ -10,7 +10,7 @@ export default function Modal({ isOpen, onClose, title, children, size = 'md' })
if (!isOpen) return null; 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 ( return (
<div <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 };
}
+247
View File
@@ -0,0 +1,247 @@
// Pure helpers for exporting daily-parameter data as a CSV matrix. The export is a
// subject-series matrix: subjects are always columns, the chosen X coordinate
// (Date / # Days Reach / a daily field) provides the rows, and one Data
// parameter supplies the cell values.
// 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;
}
// Numeric value of a daily field on one status (mirrors the metrics plot's helper).
export 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 Number.isNaN(n) ? null : n;
}
// Ordered row members for the chosen X coordinate.
// xField: '__date__' | '__days__' | <daily fieldId>
export function listSampleMembers(statuses, xField, dailyTemplate) {
const list = statuses ?? [];
if (xField === '__date__') {
const keys = new Set();
for (const s of list) if (s?.date) keys.add(dateKey(s.date));
return [...keys].sort().map((k) => ({ id: k, label: k, sampleValue: k }));
}
if (xField === '__days__') {
const counts = new Map();
for (const s of list) {
if (s?.animal_id == null || !s?.date) continue;
counts.set(s.animal_id, (counts.get(s.animal_id) ?? 0) + 1);
}
const max = counts.size ? Math.max(...counts.values()) : 0;
return Array.from({ length: max }, (_, i) => ({ id: String(i + 1), label: String(i + 1), sampleValue: i + 1 }));
}
const field = (dailyTemplate ?? []).find((f) => f.fieldId === xField);
const vals = new Set();
for (const s of list) {
const v = numericFieldVal(s, field);
if (v !== null) vals.add(v);
}
return [...vals].sort((a, b) => a - b).map((v) => ({ id: String(v), label: String(v), sampleValue: v }));
}
// animalId -> Map(sampleValue -> status). First status per (subject, sampleValue) wins,
// scanning each subject's statuses in ascending date order.
export function buildSampleIndex(statuses, xField, dailyTemplate) {
const bySubject = new Map();
for (const s of statuses ?? []) {
if (s?.animal_id == null || !s?.date) continue;
if (!bySubject.has(s.animal_id)) bySubject.set(s.animal_id, []);
bySubject.get(s.animal_id).push(s);
}
const field = xField !== '__date__' && xField !== '__days__'
? (dailyTemplate ?? []).find((f) => f.fieldId === xField)
: null;
const index = new Map();
for (const [animalId, subjStatuses] of bySubject) {
const ordered = [...subjStatuses].sort((a, b) => dateKey(a.date).localeCompare(dateKey(b.date)));
const m = new Map();
ordered.forEach((s, i) => {
let key;
if (xField === '__date__') key = dateKey(s.date);
else if (xField === '__days__') key = i + 1;
else { const v = numericFieldVal(s, field); if (v === null) return; key = v; }
if (!m.has(key)) m.set(key, s);
});
index.set(animalId, m);
}
return index;
}
function xFieldLabel(xField, dailyTemplate) {
if (xField === '__date__') return 'Date';
if (xField === '__days__') return '# Days Reach (ordinal)';
return (dailyTemplate ?? []).find((f) => f.fieldId === xField)?.label ?? '';
}
// Subjects as columns, X-field values as rows, one Data value per cell.
// Returns the { context, corner, groupAxis, columns, rows } shape toCSV consumes.
export function buildSubjectSeriesMatrix({ xField, dataParam, groupField }, { statuses, animals, dailyTemplate }) {
const columns = listSubjectMembers(animals, groupField);
const rowMembers = listSampleMembers(statuses, xField, dailyTemplate);
const index = buildSampleIndex(statuses, xField, dailyTemplate);
const cellValue = (sampleValue, animalId) => {
const status = index.get(animalId)?.get(sampleValue);
if (!status || !dataParam) return '';
const v = extractValue(status, dataParam);
return hasValue(v) ? v : '';
};
let rows = rowMembers.map((rm) => ({
member: rm,
values: columns.map((c) => cellValue(rm.sampleValue, c.animalId)),
}));
rows = rows.filter((r) => r.values.some((v) => v !== '')); // drop entirely-blank sample rows
const grouped = !!groupField && groupField !== '__none__';
return {
corner: xFieldLabel(xField, dailyTemplate),
context: { label: 'Data', value: dataParam?.label ?? '' },
groupAxis: grouped ? 'col' : null,
columns,
rows,
};
}
// 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 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;
}
// 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
}
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):
// Data,<metric>
// (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(','));
}
// toCSV is a general serializer; the subject-series builder never emits groupAxis
// === 'row' (only 'col' or null), but the branch is retained for that generality.
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');
}
// 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`;
}
+398 -54
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useParams, useNavigate, Link, useLocation } from 'react-router-dom'; import { useParams, useNavigate, Link, useLocation } from 'react-router-dom';
import { animalsApi, dailyStatusesApi, experimentsApi } from '../api/client'; import { animalsApi, dailyStatusesApi, experimentsApi } from '../api/client';
import { format } from 'date-fns'; import { format } from 'date-fns';
@@ -6,13 +6,102 @@ import Button from '../components/ui/Button';
import Modal from '../components/ui/Modal'; import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog'; import ConfirmDialog from '../components/ui/ConfirmDialog';
import DailyStatusForm from '../components/DailyStatusForm'; import DailyStatusForm from '../components/DailyStatusForm';
import SubjectInfoForm from '../components/SubjectInfoForm';
import Alert from '../components/ui/Alert'; import Alert from '../components/ui/Alert';
import AuditLogSection from '../components/AuditLogSection'; import AuditLogSection from '../components/AuditLogSection';
import { SubjectAnalysisCharts } from '../components/AnalysisCharts';
/** Render the value for a template field from a status record */ /** 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) { function getFieldValue(status, field) {
if (field.builtin) return status[field.key]; if (field.builtin) return status[field.key];
return status.custom_fields?.[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() { export default function AnimalDetail() {
@@ -22,7 +111,9 @@ export default function AnimalDetail() {
const [animal, setAnimal] = useState(null); const [animal, setAnimal] = useState(null);
const [statuses, setStatuses] = useState([]); const [statuses, setStatuses] = useState([]);
const [template, setTemplate] = useState(location.state?.template ?? []); const [template, setTemplate] = useState([]);
const [subjectTemplate, setSubjectTemplate] = useState([]);
const [showSubjectInfo, setShowSubjectInfo] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [successMsg, setSuccessMsg] = useState(null); const [successMsg, setSuccessMsg] = useState(null);
@@ -31,6 +122,40 @@ export default function AnimalDetail() {
const [editStatus, setEditStatus] = useState(null); const [editStatus, setEditStatus] = useState(null);
const [deleteStatus, setDeleteStatus] = useState(null); const [deleteStatus, setDeleteStatus] = useState(null);
const [deleteLoading, setDeleteLoading] = useState(false); 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() { async function load() {
setLoading(true); setLoading(true);
@@ -43,11 +168,12 @@ export default function AnimalDetail() {
setAnimal(animalData); setAnimal(animalData);
setStatuses(statusData); setStatuses(statusData);
// Fetch template if not passed via navigation state const [tmpl, subjTmpl] = await Promise.all([
if (!location.state?.template) { experimentsApi.getTemplate(animalData.experiment_id),
const tmpl = await experimentsApi.getTemplate(animalData.experiment_id); experimentsApi.getSubjectTemplate(animalData.experiment_id),
]);
setTemplate(tmpl); setTemplate(tmpl);
} setSubjectTemplate(subjTmpl);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -74,6 +200,7 @@ export default function AnimalDetail() {
try { try {
await dailyStatusesApi.delete(deleteStatus.id); await dailyStatusesApi.delete(deleteStatus.id);
setDeleteStatus(null); setDeleteStatus(null);
setEditStatus(null);
load(); load();
flash('Daily status removed.'); flash('Daily status removed.');
} catch (err) { } catch (err) {
@@ -84,6 +211,32 @@ export default function AnimalDetail() {
} }
} }
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 (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 ( if (error) return (
<div className="max-w-5xl mx-auto px-4 py-8"> <div className="max-w-5xl mx-auto px-4 py-8">
@@ -93,6 +246,23 @@ export default function AnimalDetail() {
); );
const activeFields = template.filter((f) => f.active); 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 ( return (
<div className="max-w-5xl mx-auto px-4 py-8"> <div className="max-w-5xl mx-auto px-4 py-8">
@@ -120,85 +290,248 @@ export default function AnimalDetail() {
{successMsg && <Alert type="success" message={successMsg} />} {successMsg && <Alert type="success" message={successMsg} />}
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />} {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 ? ( {statuses.length === 0 ? (
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl"> <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> <p className="text-gray-400 mb-3">No daily statuses logged yet.</p>
<Button onClick={() => setShowAddStatus(true)}>+ Log First Status</Button> <Button onClick={() => setShowAddStatus(true)}>+ Log First Status</Button>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="border border-gray-200 rounded-xl overflow-hidden">
{statuses.map((s) => ( {/* Column picker toolbar */}
<div key={s.id} className="bg-white border border-gray-200 rounded-xl p-5"> {activeFields.length > 0 && (
<div className="flex items-center justify-between mb-3"> <div className="flex justify-end px-3 py-1.5 border-b border-gray-100 bg-gray-50/60">
<span className="font-semibold text-gray-900"> <div className="relative" ref={colPickerRef}>
{format(new Date(s.date), 'MMMM d, yyyy')} <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> </span>
<div className="flex gap-2"> )}
<Button size="sm" variant="secondary" onClick={() => setEditStatus(s)}>Edit</Button> </button>
<Button size="sm" variant="danger" onClick={() => setDeleteStatus(s)}>Delete</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> </div>
)}
{activeFields.length > 0 ? ( <div className="overflow-x-auto">
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm"> <table className="text-sm border-collapse min-w-full">
{activeFields <thead>
.map((f) => ({ field: f, value: getFieldValue(s, f) })) <tr className="bg-gray-50 border-b border-gray-200">
.filter(({ value }) => value) <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]">
.map(({ field, value }) => ( Date
<div key={field.key} className={field.type === 'textarea' ? 'sm:col-span-2' : ''}> </th>
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">{field.label}</dt> {visibleFields.map((f) => {
<dd className="text-gray-800 mt-0.5 whitespace-pre-wrap">{value}</dd> const header = f.abbr ? (f.unit ? `${f.abbr} (${f.unit})` : f.abbr) : (f.unit ? `${f.label} (${f.unit})` : f.label);
</div> const tooltip = f.unit ? `${f.label} (${f.unit})` : f.label;
))} const minW = Math.max(80, header.length * 7.5 + 24);
</dl> return (
) : null} <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}
{/* Fallback: show raw builtin fields if template is empty */} </th>
);
})}
{activeFields.length === 0 && ( {activeFields.length === 0 && (
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm"> <>
{[ {['Experiment Description', 'Vitals', 'Treatment', 'Notes'].map((l) => (
['Experiment Description', s.experiment_description], <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>
['Vitals', s.vitals],
['Treatment', s.treatment],
['Notes', s.notes],
].filter(([, v]) => v).map(([label, value]) => (
<div key={label}>
<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> </>
)} )}
<th className="sticky right-0 bg-gray-50 z-10 border-l border-gray-200 px-3 py-2 min-w-[96px]" />
{activeFields.length > 0 && </tr>
activeFields.every((f) => !getFieldValue(s, f)) && ( </thead>
<p className="text-sm text-gray-400 italic">No details recorded for this date.</p> <tbody>
)} {statuses.map((s, i) => {
</div> 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>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div> </div>
)} )}
<AuditLogSection tableName="daily_statuses" /> {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"> <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={showAddStatus} onClose={() => setShowAddStatus(false)} title="Log Daily Status" size="full">
<DailyStatusForm <DailyStatusForm
animalId={id} animalId={id}
experimentId={animal?.experiment_id}
template={template} template={template}
onTemplateUpdate={setTemplate}
onSuccess={handleStatusSaved} onSuccess={handleStatusSaved}
onCancel={() => setShowAddStatus(false)} onCancel={() => setShowAddStatus(false)}
/> />
</Modal> </Modal>
<Modal isOpen={!!editStatus} onClose={() => setEditStatus(null)} title="Edit Daily Status" size="lg"> {(() => {
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 <DailyStatusForm
existing={editStatus} existing={editStatus}
animalId={id} animalId={id}
experimentId={animal?.experiment_id}
template={template} template={template}
onTemplateUpdate={setTemplate}
onSuccess={handleStatusSaved} onSuccess={handleStatusSaved}
onCancel={() => setEditStatus(null)} 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> </Modal>
);
})()}
<ConfirmDialog <ConfirmDialog
isOpen={!!deleteStatus} isOpen={!!deleteStatus}
@@ -206,7 +539,18 @@ export default function AnimalDetail() {
onConfirm={handleDeleteStatus} onConfirm={handleDeleteStatus}
loading={deleteLoading} loading={deleteLoading}
title="Delete Daily Status" 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> </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>
);
}
+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>
);
}
+290 -63
View File
@@ -1,13 +1,53 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useMemo } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom'; import { useParams, useNavigate, Link } from 'react-router-dom';
import { experimentsApi, animalsApi } from '../api/client'; import { experimentsApi, animalsApi } from '../api/client';
import { ExperimentAnalysisCharts } from '../components/AnalysisCharts';
import Button from '../components/ui/Button'; import Button from '../components/ui/Button';
import Modal from '../components/ui/Modal'; import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import AnimalForm from '../components/AnimalForm'; import AnimalForm from '../components/AnimalForm';
import TemplateEditor from '../components/TemplateEditor'; import TemplateEditor from '../components/TemplateEditor';
import Alert from '../components/ui/Alert'; import Alert from '../components/ui/Alert';
import AuditLogSection from '../components/AuditLogSection'; 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() { export default function ExperimentDetail() {
const { id } = useParams(); const { id } = useParams();
@@ -21,20 +61,51 @@ export default function ExperimentDetail() {
const [showAddAnimal, setShowAddAnimal] = useState(false); const [showAddAnimal, setShowAddAnimal] = useState(false);
const [editAnimal, setEditAnimal] = useState(null); const [editAnimal, setEditAnimal] = useState(null);
const [deleteAnimal, setDeleteAnimal] = useState(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [showTemplate, setShowTemplate] = 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() { async function load() {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const [exp, anims] = await Promise.all([ const [exp, anims, subjTmpl, dailyTmpl, expStatuses] = await Promise.all([
experimentsApi.get(id), experimentsApi.get(id),
animalsApi.list(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); setExperiment(exp);
setAnimals(anims); setAnimals(anims);
setSubjectTemplate(subjTmpl);
setDailyTemplate(dailyTmpl);
setExperimentStatuses(expStatuses);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -56,21 +127,6 @@ export default function ExperimentDetail() {
flash(editAnimal ? `Animal "${animal.animal_name}" updated.` : `Animal "${animal.animal_name}" added.`); 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) { function handleTemplateSaved(updatedTemplate) {
setShowTemplate(false); setShowTemplate(false);
if (updatedTemplate) { if (updatedTemplate) {
@@ -80,6 +136,49 @@ export default function ExperimentDetail() {
} }
} }
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 (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 ( if (error) return (
<div className="max-w-5xl mx-auto px-4 py-8"> <div className="max-w-5xl mx-auto px-4 py-8">
@@ -105,13 +204,7 @@ export default function ExperimentDetail() {
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="secondary" onClick={() => setShowTemplate(true)}> <Button variant="secondary" onClick={() => setShowExport(true)}>Export data</Button>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
Record Template
</Button>
<Button onClick={() => setShowAddAnimal(true)}>+ Add Animal</Button> <Button onClick={() => setShowAddAnimal(true)}>+ Add Animal</Button>
</div> </div>
</div> </div>
@@ -119,10 +212,12 @@ export default function ExperimentDetail() {
{successMsg && <Alert type="success" message={successMsg} />} {successMsg && <Alert type="success" message={successMsg} />}
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />} {error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
{/* Template summary strip */} {/* Template strips */}
{experiment.template && experiment.template.length > 0 && ( <div className="mb-5 space-y-2">
<div className="mb-5 flex flex-wrap gap-1.5"> {/* Daily record template */}
{experiment.template.map((f) => ( <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 <span
key={f.key} key={f.key}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${ className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${
@@ -135,12 +230,40 @@ export default function ExperimentDetail() {
))} ))}
<button <button
onClick={() => setShowTemplate(true)} onClick={() => setShowTemplate(true)}
className="text-xs text-gray-400 hover:text-blue-600 underline underline-offset-2 transition-colors" className="inline-flex items-center gap-1 text-xs text-gray-400 hover:text-blue-600 transition-colors"
> >
edit <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> </button>
</div> </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 ? ( {animals.length === 0 ? (
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl"> <div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl">
@@ -148,37 +271,137 @@ export default function ExperimentDetail() {
<Button onClick={() => setShowAddAnimal(true)}>+ Add First Animal</Button> <Button onClick={() => setShowAddAnimal(true)}>+ Add First Animal</Button>
</div> </div>
) : ( ) : (
<div className="grid gap-3"> <>
{animals.map((animal) => ( {/* Display controls */}
<div <div className="flex items-center gap-3 mb-3 flex-wrap">
key={animal.id} {/* Mode toggle */}
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" <div className="flex rounded border border-gray-200 overflow-hidden text-xs">
onClick={() => navigate(`/animals/${animal.id}`, { state: { template: experiment.template } })} {[['list', 'List'], ['group', 'Group']].map(([mode, label]) => (
> <button key={mode} type="button" onClick={() => updateDisplayMode(mode)}
<div> className={`px-3 py-1 border-l border-gray-200 first:border-l-0 transition-colors
<div className="font-semibold text-gray-900 group-hover:text-blue-700 transition-colors"> ${displayMode === mode ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
{animal.animal_name} {label}
</div> </button>
<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>
))} ))}
</div> </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" /> {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 */} {/* Template editor modal */}
<Modal isOpen={showTemplate} onClose={() => setShowTemplate(false)} title="Daily Record Template" size="lg"> <Modal isOpen={showTemplate} onClose={() => setShowTemplate(false)} title="Daily Record Template" size="full">
<TemplateEditor experimentId={id} onClose={handleTemplateSaved} /> <TemplateEditor experimentId={id} onClose={handleTemplateSaved} />
</Modal> </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"> <Modal isOpen={showAddAnimal} onClose={() => setShowAddAnimal(false)} title="Add Animal">
<AnimalForm experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setShowAddAnimal(false)} /> <AnimalForm experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setShowAddAnimal(false)} />
</Modal> </Modal>
@@ -187,14 +410,18 @@ export default function ExperimentDetail() {
<AnimalForm existing={editAnimal} experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setEditAnimal(null)} /> <AnimalForm existing={editAnimal} experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setEditAnimal(null)} />
</Modal> </Modal>
<ConfirmDialog <Modal isOpen={showExport} onClose={() => setShowExport(false)} title="Export data" size="md">
isOpen={!!deleteAnimal} <ExportDataModal
onClose={() => setDeleteAnimal(null)} experimentId={id}
onConfirm={handleDeleteAnimal} experimentTitle={experiment.title}
loading={deleteLoading} dailyTemplate={dailyTemplate}
title="Remove Animal" animals={animals}
message={`Remove "${deleteAnimal?.animal_name}" and all its daily statuses from this experiment?`} subjectTemplate={subjectTemplate}
groupField={localStorage.getItem(`exp-subject-group-${id}`) ?? '__none__'}
onClose={() => setShowExport(false)}
/> />
</Modal>
</div> </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: [], plugins: [],
}; };
+17 -7
View File
@@ -9,17 +9,27 @@ jest.mock('../src/api/client', () => ({
create: jest.fn(), create: jest.fn(),
update: jest.fn(), update: jest.fn(),
}, },
experimentsApi: {
updateTemplate: jest.fn(),
},
})); }));
const ANIMAL_ID = 'animal-001'; const ANIMAL_ID = 'animal-001';
const onSuccess = jest.fn(); const onSuccess = jest.fn();
const onCancel = 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()); beforeEach(() => jest.clearAllMocks());
describe('DailyStatusForm', () => { describe('DailyStatusForm', () => {
it('renders date field (required) and optional fields', () => { it('renders date field (required) and optional fields', () => {
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />); render(<DailyStatusForm animalId={ANIMAL_ID} template={BUILTIN_TEMPLATE} onSuccess={onSuccess} onCancel={onCancel} />);
expect(screen.getByLabelText(/date/i)).toBeInTheDocument(); expect(screen.getByLabelText(/date/i)).toBeInTheDocument();
expect(screen.getByLabelText(/vitals/i)).toBeInTheDocument(); expect(screen.getByLabelText(/vitals/i)).toBeInTheDocument();
expect(screen.getByLabelText(/treatment/i)).toBeInTheDocument(); expect(screen.getByLabelText(/treatment/i)).toBeInTheDocument();
@@ -28,13 +38,13 @@ describe('DailyStatusForm', () => {
}); });
it('pre-fills date with today by default', () => { it('pre-fills date with today by default', () => {
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />); render(<DailyStatusForm animalId={ANIMAL_ID} template={BUILTIN_TEMPLATE} onSuccess={onSuccess} onCancel={onCancel} />);
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
expect(screen.getByLabelText(/date/i)).toHaveValue(today); expect(screen.getByLabelText(/date/i)).toHaveValue(today);
}); });
it('shows error when date is cleared', async () => { it('shows error when date is cleared', async () => {
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />); render(<DailyStatusForm animalId={ANIMAL_ID} template={BUILTIN_TEMPLATE} onSuccess={onSuccess} onCancel={onCancel} />);
const dateInput = screen.getByLabelText(/date/i); const dateInput = screen.getByLabelText(/date/i);
await userEvent.clear(dateInput); await userEvent.clear(dateInput);
fireEvent.click(screen.getByRole('button', { name: /log status/i })); fireEvent.click(screen.getByRole('button', { name: /log status/i }));
@@ -46,11 +56,10 @@ describe('DailyStatusForm', () => {
const status = { id: 's-1', animal_id: ANIMAL_ID, date: '2024-06-01', vitals: 'HR 72' }; const status = { id: 's-1', animal_id: ANIMAL_ID, date: '2024-06-01', vitals: 'HR 72' };
client.dailyStatusesApi.create.mockResolvedValue(status); client.dailyStatusesApi.create.mockResolvedValue(status);
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />); render(<DailyStatusForm animalId={ANIMAL_ID} template={BUILTIN_TEMPLATE} onSuccess={onSuccess} onCancel={onCancel} />);
const dateInput = screen.getByLabelText(/date/i); const dateInput = screen.getByLabelText(/date/i);
await userEvent.clear(dateInput); fireEvent.change(dateInput, { target: { value: '2024-06-01' } });
await userEvent.type(dateInput, '2024-06-01');
await userEvent.type(screen.getByLabelText(/vitals/i), 'HR 72'); await userEvent.type(screen.getByLabelText(/vitals/i), 'HR 72');
@@ -76,6 +85,7 @@ describe('DailyStatusForm', () => {
render( render(
<DailyStatusForm <DailyStatusForm
existing={existing} existing={existing}
template={BUILTIN_TEMPLATE}
animalId={ANIMAL_ID} animalId={ANIMAL_ID}
onSuccess={onSuccess} onSuccess={onSuccess}
onCancel={onCancel} onCancel={onCancel}
@@ -86,7 +96,7 @@ describe('DailyStatusForm', () => {
}); });
it('calls onCancel when Cancel is clicked', () => { it('calls onCancel when Cancel is clicked', () => {
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />); render(<DailyStatusForm animalId={ANIMAL_ID} template={BUILTIN_TEMPLATE} onSuccess={onSuccess} onCancel={onCancel} />);
fireEvent.click(screen.getByRole('button', { name: /cancel/i })); fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(onCancel).toHaveBeenCalled(); 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();
});
});
+124
View File
@@ -0,0 +1,124 @@
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:00Z', analysis_summary: { total: 5, success_rate: 0.5 } },
{ animal_id: 'a1', date: '2026-07-02T00:00:00Z', analysis_summary: { total: 7, success_rate: 0.7 } },
{ animal_id: 'a2', date: '2026-07-01T00:00:00Z', analysis_summary: { total: 9, success_rate: 0.9 } },
];
const subjectTemplate = [{ fieldId: 'grp', label: 'Group', active: true }];
beforeEach(() => {
jest.clearAllMocks();
client.experimentsApi.getDailyStatuses.mockResolvedValue(statuses);
});
// Intercept the Blob handed to URL.createObjectURL so tests can read the real CSV.
// Also stub anchor.click() — jsdom treats a click on <a href="blob:..."> as an
// unimplemented navigation that throws async and can flake a later test.
function mockDownload() {
let blob = null;
const origCreate = global.URL.createObjectURL;
const origRevoke = global.URL.revokeObjectURL;
const origClick = window.HTMLAnchorElement.prototype.click;
global.URL.createObjectURL = jest.fn((b) => { blob = b; return 'blob:mock'; });
global.URL.revokeObjectURL = jest.fn();
window.HTMLAnchorElement.prototype.click = jest.fn();
return {
getBlob: () => blob,
restore: async () => {
await new Promise((r) => setTimeout(r, 0)); // flush handleExport's setTimeout revoke
global.URL.createObjectURL = origCreate;
global.URL.revokeObjectURL = origRevoke;
window.HTMLAnchorElement.prototype.click = origClick;
},
};
}
function readBlobText(blob) {
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(fr.result);
fr.onerror = reject;
fr.readAsText(blob);
});
}
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('renders X / Data / Group by pickers; X defaults to Date', async () => {
renderModal();
expect(await screen.findByLabelText('X axis (rows)')).toHaveValue('__date__');
expect(screen.getByLabelText('Data (cells)')).toBeInTheDocument();
expect(screen.getByLabelText('Group by')).toHaveValue('__none__');
await waitFor(() => expect(screen.getByText(/2 rows × 2 subjects/i)).toBeInTheDocument());
});
it('default export (X=Date, Data=Total attempts) has subject columns, date rows, blank where missing', async () => {
const dl = mockDownload();
renderModal();
await screen.findByLabelText('X axis (rows)');
await waitFor(() => expect(screen.getByText(/2 rows/i)).toBeInTheDocument());
fireEvent.click(screen.getByText('Export CSV'));
const text = await readBlobText(dl.getBlob());
expect(text).toBe('Data,Total attempts\n\nDate,Alpha,Beta\n2026-07-01,5,9\n2026-07-02,7,');
await dl.restore();
});
it('switching X to # Days Reach makes rows the day ordinals', async () => {
const dl = mockDownload();
renderModal();
fireEvent.change(await screen.findByLabelText('X axis (rows)'), { target: { value: '__days__' } });
await waitFor(() => expect(screen.getByText(/2 rows/i)).toBeInTheDocument());
fireEvent.click(screen.getByText('Export CSV'));
const text = await readBlobText(dl.getBlob());
expect(text).toBe('Data,Total attempts\n\n# Days Reach (ordinal),Alpha,Beta\n1,5,9\n2,7,');
await dl.restore();
});
it('a valid group field adds a Group row and clusters subjects', async () => {
const dl = mockDownload();
renderModal({ groupField: 'grp' });
await screen.findByLabelText('X axis (rows)');
await waitFor(() => expect(screen.getByText(/2 rows/i)).toBeInTheDocument());
fireEvent.click(screen.getByText('Export CSV'));
const text = await readBlobText(dl.getBlob());
expect(text).toMatch(/^Group,Control,Drug$/m);
await dl.restore();
});
it('a stale/unset group field coerces to None (no Group row in the export)', async () => {
const dl = mockDownload();
renderModal({ groupField: 'ghost-field' });
await screen.findByLabelText('X axis (rows)');
await waitFor(() => expect(screen.getByText(/2 rows/i)).toBeInTheDocument());
fireEvent.click(screen.getByText('Export CSV'));
const text = await readBlobText(dl.getBlob());
expect(text).not.toMatch(/^Group,/m);
await dl.restore();
});
});
+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()
);
});
});
+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();
});
});
+308
View File
@@ -0,0 +1,308 @@
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 { toCSV, csvFilename } from '../src/lib/dataExport';
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"',
);
});
});
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');
});
});
import { subjectGroupValue } from '../src/lib/dataExport';
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('—');
});
});
import { listSubjectMembers } 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)']);
});
});
import { numericFieldVal, listSampleMembers, buildSampleIndex } from '../src/lib/dataExport';
const sDaily = [{ fieldId: 'c-w', key: 'w', label: 'Weight', builtin: false, active: true }];
const sStatuses = [
{ animal_id: 'a1', date: '2026-07-01T00:00:00Z', custom_fields: { 'c-w': '250' } },
{ animal_id: 'a1', date: '2026-07-03T00:00:00Z', custom_fields: { 'c-w': '260' } },
{ animal_id: 'a2', date: '2026-07-01T00:00:00Z', custom_fields: { 'c-w': '250' } },
];
describe('numericFieldVal', () => {
it('reads builtin via key and custom via fieldId, parsed to number', () => {
expect(numericFieldVal({ weight: '250' }, { builtin: true, key: 'weight' })).toBe(250);
expect(numericFieldVal({ custom_fields: { 'c-w': '12.5' } }, { builtin: false, fieldId: 'c-w' })).toBe(12.5);
});
it('returns null for non-numeric, missing, or no field', () => {
expect(numericFieldVal({ custom_fields: { 'c-w': 'abc' } }, { builtin: false, fieldId: 'c-w' })).toBeNull();
expect(numericFieldVal({}, { builtin: true, key: 'weight' })).toBeNull();
expect(numericFieldVal({ weight: 1 }, null)).toBeNull();
});
});
describe('listSampleMembers', () => {
it('date: distinct dates sorted', () => {
expect(listSampleMembers(sStatuses, '__date__', sDaily).map((m) => m.sampleValue)).toEqual(['2026-07-01', '2026-07-03']);
});
it('# days reach: 1..max records per subject', () => {
expect(listSampleMembers(sStatuses, '__days__', sDaily).map((m) => m.sampleValue)).toEqual([1, 2]);
});
it('daily field: distinct numeric values sorted ascending', () => {
expect(listSampleMembers(sStatuses, 'c-w', sDaily).map((m) => m.sampleValue)).toEqual([250, 260]);
});
});
describe('buildSampleIndex', () => {
it('date coord maps animalId -> dateKey -> status', () => {
const idx = buildSampleIndex(sStatuses, '__date__', sDaily);
expect(idx.get('a1').get('2026-07-03').date).toContain('2026-07-03');
});
it('# days reach assigns per-subject ordinals in date order', () => {
const idx = buildSampleIndex(sStatuses, '__days__', sDaily);
expect(idx.get('a1').get(1).date).toContain('2026-07-01');
expect(idx.get('a1').get(2).date).toContain('2026-07-03');
expect(idx.get('a2').get(1).date).toContain('2026-07-01');
expect(idx.get('a2').has(2)).toBe(false);
});
it('daily field maps numeric value to first status by date', () => {
const idx = buildSampleIndex(sStatuses, 'c-w', sDaily);
expect(idx.get('a1').get(250).date).toContain('2026-07-01');
expect(idx.get('a1').get(260).date).toContain('2026-07-03');
});
it('# days reach counts per-status, not per-distinct-date (same-day statuses get separate ordinals)', () => {
const sameDay = [
{ animal_id: 'z1', date: '2026-07-01T08:00:00Z', analysis_summary: { total: 1 } },
{ animal_id: 'z1', date: '2026-07-01T20:00:00Z', analysis_summary: { total: 2 } },
];
const idx = buildSampleIndex(sameDay, '__days__', []);
expect(idx.get('z1').has(1)).toBe(true);
expect(idx.get('z1').has(2)).toBe(true);
expect(idx.get('z1').has(3)).toBe(false);
});
});
import { buildSubjectSeriesMatrix } from '../src/lib/dataExport';
const ssAnimals = [
{ 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 ssStatuses = [
{ animal_id: 'a1', date: '2026-07-01T00:00:00Z', analysis_summary: { total: 5 } },
{ animal_id: 'a1', date: '2026-07-02T00:00:00Z', analysis_summary: { total: 7 } },
{ animal_id: 'a2', date: '2026-07-01T00:00:00Z', analysis_summary: { total: 9 } },
];
const totalParam = { id: '__total__', label: 'Total attempts', kind: 'total', group: 'metrics' };
describe('buildSubjectSeriesMatrix', () => {
it('date X: subjects as columns, date rows, blank where missing, context names the metric', () => {
const m = buildSubjectSeriesMatrix(
{ xField: '__date__', dataParam: totalParam, groupField: '__none__' },
{ statuses: ssStatuses, animals: ssAnimals, dailyTemplate: [] },
);
expect(m.corner).toBe('Date');
expect(m.context).toEqual({ label: 'Data', value: 'Total attempts' });
expect(m.groupAxis).toBeNull();
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, '']);
});
it('# days reach X: rows align each subject by day ordinal', () => {
const m = buildSubjectSeriesMatrix(
{ xField: '__days__', dataParam: totalParam, groupField: '__none__' },
{ statuses: ssStatuses, animals: ssAnimals, dailyTemplate: [] },
);
expect(m.corner).toBe('# Days Reach (ordinal)');
expect(m.rows.map((r) => r.member.label)).toEqual(['1', '2']);
expect(m.rows[0].values).toEqual([5, 9]);
expect(m.rows[1].values).toEqual([7, '']);
});
it('grouping sets groupAxis col and clusters subjects', () => {
const m = buildSubjectSeriesMatrix(
{ xField: '__date__', dataParam: totalParam, groupField: 'grp' },
{ statuses: ssStatuses, animals: ssAnimals, dailyTemplate: [] },
);
expect(m.groupAxis).toBe('col');
expect(m.columns.map((c) => [c.group, c.label])).toEqual([['Control', 'Alpha'], ['Drug', 'Beta']]);
});
it('drops entirely-blank rows', () => {
const statuses = [
{ animal_id: 'a1', date: '2026-07-01T00:00:00Z', analysis_summary: { total: 5 } },
{ animal_id: 'a1', date: '2026-07-05T00:00:00Z', analysis_summary: null },
];
const m = buildSubjectSeriesMatrix(
{ xField: '__date__', dataParam: totalParam, groupField: '__none__' },
{ statuses, animals: ssAnimals, dailyTemplate: [] },
);
expect(m.rows.map((r) => r.member.label)).toEqual(['2026-07-01']);
});
});
+12
View File
@@ -1 +1,13 @@
import '@testing-library/jest-dom'; 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; });
+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

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