feat: permanent calendar with editable day modal and per-subject bar chart

- Calendar moved out of List/Group tab toggle — always visible below cross-
  subject analytics (ExperimentAnalysisCharts)
- Day modal shows all subjects: existing data as read-only + Edit button,
  missing data as + Add button; both open DailyStatusForm inline
- Status changes/creates propagate up to parent via onStatusChange/onStatusCreate
  and calendar counts recompute locally (no extra API round-trip)
- Bar chart (recharts) at bottom: Total/Success/Failure bars per subject plus
  % complete text, grouped by subject name
- Tests updated: 17 ExperimentCalendar tests, 76 total passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Experiments DB Dev
2026-04-23 11:30:26 -04:00
parent 755caca09e
commit 4a048fda88
2 changed files with 116 additions and 142 deletions
+8 -4
View File
@@ -12,11 +12,11 @@
},
"dependencies": {
"axios": "^1.7.2",
"recharts": "^2.12.7",
"date-fns": "^3.6.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.24.0"
"react-router-dom": "^6.24.0",
"recharts": "^2.15.4"
},
"devDependencies": {
"@babel/core": "^7.24.7",
@@ -38,8 +38,12 @@
},
"jest": {
"testEnvironment": "jsdom",
"setupFilesAfterEnv": ["<rootDir>/tests/setup.js"],
"testMatch": ["**/tests/**/*.test.{js,jsx}"],
"setupFilesAfterEnv": [
"<rootDir>/tests/setup.js"
],
"testMatch": [
"**/tests/**/*.test.{js,jsx}"
],
"transform": {
"^.+\\.[jt]sx?$": "babel-jest"
},
+108 -138
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
import ExperimentCalendar from '../src/components/ExperimentCalendar';
import { experimentsApi } from '../src/api/client';
@@ -9,6 +9,17 @@ jest.mock('../src/api/client', () => ({
},
}));
jest.mock('recharts', () => ({
ResponsiveContainer: ({ children }) => <div>{children}</div>,
BarChart: ({ children }) => <div data-testid="bar-chart">{children}</div>,
Bar: () => null,
XAxis: () => null,
YAxis: () => null,
CartesianGrid: () => null,
Tooltip: () => null,
Legend: () => null,
}));
const EXP_ID = 'aaaaaaaa-0000-0000-0000-000000000001';
const TEMPLATE = [
@@ -22,18 +33,18 @@ const ANIMALS = [
{ id: 'a2000000-0000-0000-0000-000000000002', animal_name: 'Rat B', animal_id_string: 'R002' },
];
function todayYYYYMM() {
function todayStr() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function makeStatus(animalId, date, customFields = {}) {
function makeStatus(animalId, date, customFields = {}, vitals = null) {
return {
id: `s-${animalId}-${date}`,
animal_id: animalId,
date: `${date}T00:00:00.000Z`,
experiment_description: null,
vitals: null,
vitals,
treatment: null,
notes: null,
custom_fields: customFields,
@@ -42,29 +53,21 @@ function makeStatus(animalId, date, customFields = {}) {
beforeEach(() => {
jest.clearAllMocks();
experimentsApi.getCalendar.mockResolvedValue({ field: 'ww000000-0000-0000-0000-000000000099', days: {} });
});
// ── Rendering ──────────────────────────────────────────────────────────────────
describe('ExperimentCalendar rendering', () => {
it('renders the current month and year', async () => {
it('renders the current month and year', () => {
render(
<ExperimentCalendar
experimentId={EXP_ID}
template={TEMPLATE}
allStatuses={[]}
animals={ANIMALS}
/>,
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
);
const now = new Date();
const monthLabel = now.toLocaleString('en-US', { month: 'long', year: 'numeric' });
await waitFor(() => {
expect(screen.getByText(monthLabel)).toBeInTheDocument();
});
expect(screen.getByText(monthLabel)).toBeInTheDocument();
});
it('renders day-of-week headers', async () => {
it('renders day-of-week headers', () => {
render(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
);
@@ -73,11 +76,11 @@ describe('ExperimentCalendar rendering', () => {
}
});
it('renders the field selector with active template fields only', async () => {
it('renders the field selector with active template fields only', () => {
render(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
);
await waitFor(() => expect(screen.getByTestId('field-select')).toBeInTheDocument());
expect(screen.getByTestId('field-select')).toBeInTheDocument();
const options = screen.getAllByRole('option');
const labels = options.map((o) => o.textContent);
expect(labels).toContain('Vitals');
@@ -89,19 +92,14 @@ describe('ExperimentCalendar rendering', () => {
// ── Default field selection ────────────────────────────────────────────────────
describe('default field selection', () => {
it('defaults to the field whose label contains "weight"', async () => {
it('defaults to the field whose label contains "weight"', () => {
render(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
);
await waitFor(() => {
expect(experimentsApi.getCalendar).toHaveBeenCalledWith(
EXP_ID,
'ww000000-0000-0000-0000-000000000099',
);
});
expect(screen.getByTestId('field-select')).toHaveValue('ww000000-0000-0000-0000-000000000099');
});
it('defaults to first field if none contains "weight"', async () => {
it('defaults to first field if none contains "weight"', () => {
const noWeightTemplate = [
{ fieldId: 'ff000000-0000-0000-0000-000000000001', key: 'treatment', label: 'Treatment', active: true, builtin: true },
{ fieldId: 'ff000000-0000-0000-0000-000000000002', key: 'vitals', label: 'Vitals', active: true, builtin: true },
@@ -109,189 +107,161 @@ describe('default field selection', () => {
render(
<ExperimentCalendar experimentId={EXP_ID} template={noWeightTemplate} allStatuses={[]} animals={ANIMALS} />,
);
await waitFor(() => {
expect(experimentsApi.getCalendar).toHaveBeenCalledWith(EXP_ID, 'treatment');
});
expect(screen.getByTestId('field-select')).toHaveValue('treatment');
});
});
// ── Count badges ──────────────────────────────────────────────────────────────
// ── Count badges (computed from allStatuses) ───────────────────────────────────
describe('count badges', () => {
it('shows a count badge on days returned by the API', async () => {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
const dateStr = `${year}-${month}-${day}`;
experimentsApi.getCalendar.mockResolvedValue({
field: 'ww000000-0000-0000-0000-000000000099',
days: { [dateStr]: 3 },
});
it('shows a count badge for a day where the selected field is non-empty', () => {
const dateStr = todayStr();
const statuses = [
makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }),
makeStatus('a2000000-0000-0000-0000-000000000002', dateStr, { 'ww000000-0000-0000-0000-000000000099': '310' }),
];
render(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={ANIMALS} />,
);
await waitFor(() => {
expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('3');
});
expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('2');
});
it('does not show count badge on days with no data', async () => {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
const dateStr = `${year}-${month}-${day}`;
experimentsApi.getCalendar.mockResolvedValue({ field: 'vitals', days: {} });
it('does not show count badge on days with no data', () => {
const dateStr = todayStr();
render(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
);
await waitFor(() => expect(experimentsApi.getCalendar).toHaveBeenCalled());
expect(screen.queryByTestId(`count-${dateStr}`)).not.toBeInTheDocument();
});
it('shows a count badge for builtin field (vitals)', () => {
const dateStr = todayStr();
const statuses = [makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, {}, 'HR 72')];
render(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={ANIMALS} />,
);
// Switch to vitals field
fireEvent.change(screen.getByTestId('field-select'), { target: { value: 'vitals' } });
expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('1');
});
});
// ── Month navigation ──────────────────────────────────────────────────────────
describe('month navigation', () => {
it('moves to the previous month on click', async () => {
it('moves to the previous month on click', () => {
render(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
);
const now = new Date();
const prev = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const prevLabel = prev.toLocaleString('en-US', { month: 'long', year: 'numeric' });
fireEvent.click(screen.getByLabelText('Previous month'));
await waitFor(() => expect(screen.getByText(prevLabel)).toBeInTheDocument());
expect(screen.getByText(prevLabel)).toBeInTheDocument();
});
it('moves to the next month on click', async () => {
it('moves to the next month on click', () => {
render(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
);
const now = new Date();
const next = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const nextLabel = next.toLocaleString('en-US', { month: 'long', year: 'numeric' });
fireEvent.click(screen.getByLabelText('Next month'));
await waitFor(() => expect(screen.getByText(nextLabel)).toBeInTheDocument());
expect(screen.getByText(nextLabel)).toBeInTheDocument();
});
it('re-fetches calendar data after field change', async () => {
it('recomputes counts after field change', () => {
const dateStr = todayStr();
// Only has vitals, not weights
const statuses = [makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, {}, 'HR 72')];
render(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={ANIMALS} />,
);
await waitFor(() => expect(experimentsApi.getCalendar).toHaveBeenCalledTimes(1));
// Default is weights — no badge
expect(screen.queryByTestId(`count-${dateStr}`)).not.toBeInTheDocument();
// Switch to vitals — badge appears
fireEvent.change(screen.getByTestId('field-select'), { target: { value: 'vitals' } });
await waitFor(() => expect(experimentsApi.getCalendar).toHaveBeenCalledTimes(2));
expect(experimentsApi.getCalendar).toHaveBeenLastCalledWith(EXP_ID, 'vitals');
expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('1');
});
});
// ── Day click → modal ─────────────────────────────────────────────────────────
describe('day click modal', () => {
it('opens a modal showing subject data when a day with data is clicked', async () => {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
const dateStr = `${year}-${month}-${day}`;
experimentsApi.getCalendar.mockResolvedValue({
field: 'ww000000-0000-0000-0000-000000000099',
days: { [dateStr]: 1 },
});
it('opens a modal when a day with data is clicked', async () => {
const dateStr = todayStr();
const statuses = [
makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, {
'ww000000-0000-0000-0000-000000000099': '325',
}),
makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }),
];
render(
<ExperimentCalendar
experimentId={EXP_ID}
template={TEMPLATE}
allStatuses={statuses}
animals={ANIMALS}
/>,
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={ANIMALS} />,
);
await waitFor(() => expect(screen.getByTestId(`day-${dateStr}`)).not.toBeDisabled());
fireEvent.click(screen.getByTestId(`day-${dateStr}`));
await waitFor(() => {
expect(screen.getByText('Rat A')).toBeInTheDocument();
expect(screen.getByText('325')).toBeInTheDocument();
const dialog = screen.getByRole('dialog');
expect(within(dialog).getByText('Rat A')).toBeInTheDocument();
});
});
it('does not open a modal when clicking a day without data', async () => {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
const dateStr = `${year}-${month}-${day}`;
experimentsApi.getCalendar.mockResolvedValue({ field: 'vitals', days: {} });
it('shows subject value in read-only view after clicking day', async () => {
const dateStr = todayStr();
const statuses = [
makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }),
];
render(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={ANIMALS} />,
);
fireEvent.click(screen.getByTestId(`day-${dateStr}`));
await waitFor(() => expect(screen.getByText('325')).toBeInTheDocument());
});
it('does not open a modal when clicking a day without data', () => {
const dateStr = todayStr();
render(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
);
await waitFor(() => expect(experimentsApi.getCalendar).toHaveBeenCalled());
// Day button is disabled — click should not open modal
const dayBtn = screen.getByTestId(`day-${dateStr}`);
expect(dayBtn).toBeDisabled();
fireEvent.click(dayBtn);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('shows all subjects with data on the selected day', async () => {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
const dateStr = `${year}-${month}-${day}`;
experimentsApi.getCalendar.mockResolvedValue({
field: 'ww000000-0000-0000-0000-000000000099',
days: { [dateStr]: 2 },
});
it('shows all animals in the modal (with and without data)', async () => {
const dateStr = todayStr();
const statuses = [
makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '320' }),
makeStatus('a2000000-0000-0000-0000-000000000002', dateStr, { 'ww000000-0000-0000-0000-000000000099': '310' }),
];
render(
<ExperimentCalendar
experimentId={EXP_ID}
template={TEMPLATE}
allStatuses={statuses}
animals={ANIMALS}
/>,
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={ANIMALS} />,
);
await waitFor(() => expect(screen.getByTestId(`day-${dateStr}`)).not.toBeDisabled());
fireEvent.click(screen.getByTestId(`day-${dateStr}`));
await waitFor(() => {
expect(screen.getByText('Rat A')).toBeInTheDocument();
expect(screen.getByText('Rat B')).toBeInTheDocument();
const dialog = screen.getByRole('dialog');
expect(within(dialog).getByText('Rat A')).toBeInTheDocument();
expect(within(dialog).getByText('Rat B')).toBeInTheDocument(); // shown even without data
});
});
});
// ── Bar chart ─────────────────────────────────────────────────────────────────
describe('bar chart', () => {
it('renders bar chart when animals have statuses', () => {
const dateStr = todayStr();
const statuses = [
makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }),
];
render(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={ANIMALS} />,
);
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
});
it('does not render bar chart when no animals have statuses', () => {
render(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
);
expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument();
});
});