diff --git a/backend/src/lib/cache.js b/backend/src/lib/cache.js new file mode 100644 index 0000000..4b91c50 --- /dev/null +++ b/backend/src/lib/cache.js @@ -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 }; diff --git a/backend/src/routes/dailyStatuses.js b/backend/src/routes/dailyStatuses.js index e35c0ae..766ecee 100644 --- a/backend/src/routes/dailyStatuses.js +++ b/backend/src/routes/dailyStatuses.js @@ -1,9 +1,20 @@ const router = require('express').Router(); const { body, query } = require('express-validator'); const prisma = require('../lib/prisma'); +const cache = require('../lib/cache'); const auditMiddleware = require('../middleware/auditMiddleware'); 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 = [ 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)'), @@ -71,6 +82,9 @@ router.post('/', statusValidation, validateRequest, async (req, res, next) => { await prisma.auditLog.create({ 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); } catch (err) { next(err); } }); @@ -93,6 +107,7 @@ router.put( if (custom_fields !== undefined) data.custom_fields = custom_fields; const status = await prisma.dailyStatus.update({ where: { id: req.params.id }, data }); + await invalidateByStatusId(req.params.id); res.json(status); } catch (err) { next(err); } } @@ -101,7 +116,10 @@ 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 } }); + 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; @@ -121,6 +139,7 @@ router.put('/:id/analysis-summary', async (req, res, next) => { 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); } }); @@ -131,7 +150,13 @@ router.delete( auditMiddleware('daily_statuses', prisma.dailyStatus), async (req, res, next) => { 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 } }); + if (rec?.animal?.experiment_id) cache.delByPrefix(`exp:${rec.animal.experiment_id}:`); res.status(204).send(); } catch (err) { next(err); } } diff --git a/backend/src/routes/experiments.js b/backend/src/routes/experiments.js index 6c0787e..3251884 100644 --- a/backend/src/routes/experiments.js +++ b/backend/src/routes/experiments.js @@ -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); } }); diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 6c4bffd..4a42aec 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -33,7 +33,11 @@ export const experimentsApi = { 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), - getDailyStatuses: (id) => api.get(`/experiments/${id}/daily-statuses`).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), }; diff --git a/frontend/src/components/ExperimentCalendar.jsx b/frontend/src/components/ExperimentCalendar.jsx index afffe2d..a84d72a 100644 --- a/frontend/src/components/ExperimentCalendar.jsx +++ b/frontend/src/components/ExperimentCalendar.jsx @@ -4,10 +4,9 @@ import { getDay, addMonths, subMonths, } from 'date-fns'; import { useNavigate } from 'react-router-dom'; +import { experimentsApi } from '../api/client'; -const BUILTIN_KEYS_SET = new Set(['experiment_description', 'vitals', 'treatment', 'notes']); - -export default function ExperimentCalendar({ experimentId, template, allStatuses }) { +export default function ExperimentCalendar({ experimentId, template }) { const navigate = useNavigate(); const [currentMonth, setCurrentMonth] = useState(() => new Date()); const [selectedField, setSelectedField] = useState(null); @@ -26,17 +25,12 @@ export default function ExperimentCalendar({ experimentId, template, allStatuses useEffect(() => { if (!selectedField) return; - const isBuiltin = BUILTIN_KEYS_SET.has(selectedField); - const days = {}; - allStatuses.forEach((s) => { - const val = isBuiltin ? s[selectedField] : s.custom_fields?.[selectedField]; - if (val !== null && val !== undefined && String(val).trim() !== '') { - const key = s.date.slice(0, 10); - days[key] = (days[key] || 0) + 1; - } - }); - setCalendarDays(days); - }, [allStatuses, selectedField]); + 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); diff --git a/frontend/src/pages/ExperimentDetail.jsx b/frontend/src/pages/ExperimentDetail.jsx index b5fd340..13b59be 100644 --- a/frontend/src/pages/ExperimentDetail.jsx +++ b/frontend/src/pages/ExperimentDetail.jsx @@ -95,7 +95,9 @@ export default function ExperimentDetail() { animalsApi.list(id), experimentsApi.getSubjectTemplate(id), experimentsApi.getTemplate(id), - experimentsApi.getDailyStatuses(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); setAnimals(anims); @@ -371,7 +373,6 @@ export default function ExperimentDetail() { )}