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
+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;
+3 -1
View File
@@ -13,6 +13,7 @@ const animalValidation = [
const animalUpdateValidation = [
body('animal_id_string').optional().trim().notEmpty().withMessage('animal_id_string cannot be empty'),
body('animal_name').optional().trim().notEmpty().withMessage('animal_name cannot be empty'),
body('subject_info').optional({ nullable: true }).isObject().withMessage('subject_info must be an object'),
];
// GET /api/animals?experimentId=<uuid> — list for experiment
@@ -80,10 +81,11 @@ router.put(
validateRequest,
async (req, res, next) => {
try {
const { animal_id_string, animal_name } = req.body;
const { animal_id_string, animal_name, subject_info } = req.body;
const data = {};
if (animal_id_string !== undefined) data.animal_id_string = animal_id_string;
if (animal_name !== undefined) data.animal_name = animal_name;
if (subject_info !== undefined) data.subject_info = subject_info;
const animal = await prisma.animal.update({
where: { id: req.params.id },
+37 -10
View File
@@ -7,20 +7,20 @@ const { validateRequest } = require('../middleware/errorHandler');
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('date').notEmpty().withMessage('date is required').isISO8601().withMessage('date must be a valid ISO 8601 date (YYYY-MM-DD)'),
body('experiment_description').optional().isString(),
body('vitals').optional().isString(),
body('treatment').optional().isString(),
body('notes').optional().isString(),
body('custom_fields').optional().isObject().withMessage('custom_fields must be an object'),
body('experiment_description').optional({ nullable: true }).isString(),
body('vitals').optional({ nullable: true }).isString(),
body('treatment').optional({ nullable: true }).isString(),
body('notes').optional({ nullable: true }).isString(),
body('custom_fields').optional({ nullable: true }).isObject().withMessage('custom_fields must be an object'),
];
const statusUpdateValidation = [
body('date').optional().isISO8601().withMessage('date must be a valid ISO 8601 date'),
body('experiment_description').optional().isString(),
body('vitals').optional().isString(),
body('treatment').optional().isString(),
body('notes').optional().isString(),
body('custom_fields').optional().isObject().withMessage('custom_fields must be an object'),
body('experiment_description').optional({ nullable: true }).isString(),
body('vitals').optional({ nullable: true }).isString(),
body('treatment').optional({ nullable: true }).isString(),
body('notes').optional({ nullable: true }).isString(),
body('custom_fields').optional({ nullable: true }).isObject().withMessage('custom_fields must be an object'),
];
// GET /api/daily-statuses?animalId=<uuid>
@@ -98,6 +98,33 @@ router.put(
}
);
// 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 } });
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 },
});
res.json(updated);
} catch (err) { next(err); }
});
// DELETE /api/daily-statuses/:id
router.delete(
'/:id',
+187
View File
@@ -55,6 +55,33 @@ function validateTemplate(template) {
return `field "${field.key}" is a builtin field and cannot be made non-builtin`;
if (!BUILTIN_KEYS.has(field.key) && field.builtin !== false)
return `custom field "${field.key}" must have builtin: false`;
// ── abbr / unit / width ────────────────────────────────────────────────────
if (field.abbr !== undefined && field.abbr !== null) {
if (typeof field.abbr !== 'string') return `abbr for "${field.key}" must be a string`;
if (field.abbr.length > 10) return `abbr for "${field.key}" must be ≤10 characters`;
}
if (field.unit !== undefined && field.unit !== null) {
if (typeof field.unit !== 'string') return `unit for "${field.key}" must be a string`;
if (field.unit.length > 10) return `unit for "${field.key}" must be ≤10 characters`;
}
if (field.width !== undefined && field.width !== null) {
if (!Number.isInteger(field.width) || field.width < 1 || field.width > 100)
return `width for "${field.key}" must be an integer between 1 and 100`;
}
// ── tags ───────────────────────────────────────────────────────────────────
if (field.tags !== undefined) {
if (!Array.isArray(field.tags)) return `tags for "${field.key}" must be an array`;
if (field.tags.length > 50) return `field "${field.key}" may have at most 50 tags`;
for (const tag of field.tags) {
if (typeof tag !== 'string' || !tag.trim()) return `tags for "${field.key}" must be non-empty strings`;
if (tag.length > 512) return `a tag in "${field.key}" exceeds 512 characters`;
}
}
if (field.tagsDisabled !== undefined && typeof field.tagsDisabled !== 'boolean')
return `tagsDisabled for "${field.key}" must be a boolean`;
}
return null;
}
@@ -68,6 +95,7 @@ function ensureFieldIds(template) {
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)
}));
}
@@ -138,6 +166,63 @@ 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;
}
// ── Template endpoints ─────────────────────────────────────────────────────────
// GET /api/experiments/:id/template
@@ -181,4 +266,106 @@ router.put('/:id/template', async (req, res, next) => {
} 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); }
});
// 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 exp = await prisma.experiment.findUnique({ where: { id: req.params.id } });
if (!exp) return res.status(404).json({ error: 'Experiment not found' });
const field = req.query.field ?? '';
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;
}
}
res.json({ field, days });
} catch (err) { next(err); }
});
// GET /api/experiments/:id/daily-statuses — all statuses for all subjects, analysis_summary included
router.get('/:id/daily-statuses', async (req, res, next) => {
try {
const exp = await prisma.experiment.findUnique({ where: { id: req.params.id } });
if (!exp) return res.status(404).json({ error: 'Experiment not found' });
const statuses = await prisma.dailyStatus.findMany({
where: { animal: { experiment_id: req.params.id } },
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' },
});
res.json(statuses);
} catch (err) { next(err); }
});
module.exports = router;