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>
This commit is contained in:
Experiments DB Dev
2026-04-23 09:26:35 -04:00
parent 09a853e28b
commit 9535f86970
34 changed files with 5460 additions and 340 deletions
+220
View File
@@ -0,0 +1,220 @@
/**
* 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,
};
}