Files
experiments-database/backend/tests/calendar.test.js
T
Experiments DB Dev 9535f86970 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>
2026-04-23 09:26:35 -04:00

130 lines
5.0 KiB
JavaScript

const request = require('supertest');
jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma'));
const prisma = require('../src/lib/prisma');
const app = require('../src/app');
const EXP_ID = 'aaaaaaaa-0000-0000-0000-000000000001';
const EXPERIMENT = {
id: EXP_ID,
title: 'Calendar Test Study',
created_at: new Date('2024-01-01').toISOString(),
};
// Helper: build a DailyStatus record
function makeStatus({ date, vitals = null, customFields = null }) {
return {
date: new Date(date),
experiment_description: null,
vitals,
treatment: null,
notes: null,
custom_fields: customFields,
};
}
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET /api/experiments/:id/calendar', () => {
it('returns 404 when experiment does not exist', async () => {
prisma.experiment.findUnique.mockResolvedValue(null);
const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar?field=vitals`);
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found/i);
});
it('returns empty days object when no statuses exist', async () => {
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
prisma.dailyStatus.findMany.mockResolvedValue([]);
const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar?field=vitals`);
expect(res.status).toBe(200);
expect(res.body.days).toEqual({});
expect(res.body.field).toBe('vitals');
});
it('counts subjects with non-empty builtin field per day', async () => {
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
prisma.dailyStatus.findMany.mockResolvedValue([
makeStatus({ date: '2026-03-10', vitals: '120/80' }),
makeStatus({ date: '2026-03-10', vitals: '110/70' }),
makeStatus({ date: '2026-03-11', vitals: '115/75' }),
makeStatus({ date: '2026-03-12', vitals: null }), // null — not counted
makeStatus({ date: '2026-03-12', vitals: '' }), // empty — not counted
]);
const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar?field=vitals`);
expect(res.status).toBe(200);
expect(res.body.days).toEqual({
'2026-03-10': 2,
'2026-03-11': 1,
});
});
it('counts subjects with non-empty custom field per day', async () => {
const FIELD_ID = 'cccccccc-0000-0000-0000-000000000099';
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
prisma.dailyStatus.findMany.mockResolvedValue([
makeStatus({ date: '2026-04-01', customFields: { [FIELD_ID]: '320' } }),
makeStatus({ date: '2026-04-01', customFields: { [FIELD_ID]: '310' } }),
makeStatus({ date: '2026-04-01', customFields: { [FIELD_ID]: null } }), // null — not counted
makeStatus({ date: '2026-04-02', customFields: { [FIELD_ID]: '315' } }),
]);
const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar?field=${FIELD_ID}`);
expect(res.status).toBe(200);
expect(res.body.days).toEqual({
'2026-04-01': 2,
'2026-04-02': 1,
});
});
it('ignores whitespace-only custom field values', async () => {
const FIELD_ID = 'dddddddd-0000-0000-0000-000000000099';
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
prisma.dailyStatus.findMany.mockResolvedValue([
makeStatus({ date: '2026-04-05', customFields: { [FIELD_ID]: ' ' } }),
makeStatus({ date: '2026-04-05', customFields: { [FIELD_ID]: '280' } }),
]);
const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar?field=${FIELD_ID}`);
expect(res.status).toBe(200);
expect(res.body.days).toEqual({ '2026-04-05': 1 });
});
it('returns days from multiple months correctly', async () => {
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
prisma.dailyStatus.findMany.mockResolvedValue([
makeStatus({ date: '2026-02-15', vitals: '100/60' }),
makeStatus({ date: '2026-03-01', vitals: '105/65' }),
makeStatus({ date: '2026-03-01', vitals: '108/68' }),
]);
const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar?field=vitals`);
expect(res.status).toBe(200);
expect(res.body.days['2026-02-15']).toBe(1);
expect(res.body.days['2026-03-01']).toBe(2);
});
it('returns field key in response body', async () => {
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
prisma.dailyStatus.findMany.mockResolvedValue([]);
const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar?field=treatment`);
expect(res.status).toBe(200);
expect(res.body.field).toBe('treatment');
});
it('handles missing field param gracefully (empty days)', async () => {
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
prisma.dailyStatus.findMany.mockResolvedValue([
makeStatus({ date: '2026-04-01', vitals: 'present' }),
]);
// No ?field= → all values undefined, nothing counted
const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar`);
expect(res.status).toBe(200);
expect(res.body.days).toEqual({});
});
});