9535f86970
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>
116 lines
3.6 KiB
JavaScript
116 lines
3.6 KiB
JavaScript
const router = require('express').Router();
|
|
const { body, query } = require('express-validator');
|
|
const prisma = require('../lib/prisma');
|
|
const auditMiddleware = require('../middleware/auditMiddleware');
|
|
const { validateRequest } = require('../middleware/errorHandler');
|
|
|
|
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('animal_id_string').trim().notEmpty().withMessage('animal_id_string is required'),
|
|
body('animal_name').trim().notEmpty().withMessage('animal_name is required'),
|
|
];
|
|
|
|
const animalUpdateValidation = [
|
|
body('animal_id_string').optional().trim().notEmpty().withMessage('animal_id_string cannot be empty'),
|
|
body('animal_name').optional().trim().notEmpty().withMessage('animal_name cannot be empty'),
|
|
body('subject_info').optional({ nullable: true }).isObject().withMessage('subject_info must be an object'),
|
|
];
|
|
|
|
// GET /api/animals?experimentId=<uuid> — list for experiment
|
|
router.get(
|
|
'/',
|
|
[query('experimentId').optional().matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('experimentId must be a valid UUID')],
|
|
validateRequest,
|
|
async (req, res, next) => {
|
|
try {
|
|
const where = req.query.experimentId ? { experiment_id: req.query.experimentId } : {};
|
|
const animals = await prisma.animal.findMany({
|
|
where,
|
|
orderBy: { animal_name: 'asc' },
|
|
include: { _count: { select: { daily_statuses: true } } },
|
|
});
|
|
res.json(animals);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
}
|
|
);
|
|
|
|
// GET /api/animals/:id — get one with daily statuses
|
|
router.get('/:id', async (req, res, next) => {
|
|
try {
|
|
const animal = await prisma.animal.findUnique({
|
|
where: { id: req.params.id },
|
|
include: { daily_statuses: { orderBy: { date: 'desc' } } },
|
|
});
|
|
if (!animal) return res.status(404).json({ error: 'Animal not found' });
|
|
res.json(animal);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// POST /api/animals — create
|
|
router.post('/', animalValidation, validateRequest, async (req, res, next) => {
|
|
try {
|
|
const { experiment_id, animal_id_string, animal_name } = req.body;
|
|
const animal = await prisma.animal.create({
|
|
data: { experiment_id, animal_id_string, animal_name },
|
|
});
|
|
|
|
await prisma.auditLog.create({
|
|
data: {
|
|
table_name: 'animals',
|
|
record_id: animal.id,
|
|
action: 'CREATE',
|
|
changes: { before: null, after: animal },
|
|
},
|
|
});
|
|
|
|
res.status(201).json(animal);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// PUT /api/animals/:id — update
|
|
router.put(
|
|
'/:id',
|
|
auditMiddleware('animals', prisma.animal),
|
|
animalUpdateValidation,
|
|
validateRequest,
|
|
async (req, res, next) => {
|
|
try {
|
|
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 },
|
|
data,
|
|
});
|
|
res.json(animal);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
}
|
|
);
|
|
|
|
// DELETE /api/animals/:id — delete
|
|
router.delete(
|
|
'/:id',
|
|
auditMiddleware('animals', prisma.animal),
|
|
async (req, res, next) => {
|
|
try {
|
|
await prisma.animal.delete({ where: { id: req.params.id } });
|
|
res.status(204).send();
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
}
|
|
);
|
|
|
|
module.exports = router;
|