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>
This commit is contained in:
Experiments DB Dev
2026-04-23 14:03:59 -04:00
parent 52d5fca5d1
commit d2c8d69718
6 changed files with 102 additions and 22 deletions
+27 -4
View File
@@ -1,6 +1,7 @@
const router = require('express').Router();
const { body } = require('express-validator');
const prisma = require('../lib/prisma');
const cache = require('../lib/cache');
const auditMiddleware = require('../middleware/auditMiddleware');
const { validateRequest } = require('../middleware/errorHandler');
const { v4: uuidv4 } = require('uuid');
@@ -311,10 +312,15 @@ router.put('/:id/subject-template', async (req, res, next) => {
// 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 field = req.query.field ?? '';
const BUILTIN_KEYS_SET = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
const isBuiltin = BUILTIN_KEYS_SET.has(field);
@@ -339,22 +345,37 @@ router.get('/:id/calendar', async (req, res, next) => {
}
}
res.json({ field, days });
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 (req.query.date) {
const d = new Date(req.query.date + 'T00:00:00.000Z');
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 };
}
if (analysisOnly) {
where.NOT = { analysis_summary: null };
}
const statuses = await prisma.dailyStatus.findMany({
where,
select: {
@@ -370,6 +391,8 @@ router.get('/:id/daily-statuses', async (req, res, next) => {
},
orderBy: { date: 'asc' },
});
cache.set(cacheKey, statuses);
res.json(statuses);
} catch (err) { next(err); }
});