diff --git a/backend/src/routes/experiments.js b/backend/src/routes/experiments.js index 2e9f106..6c0787e 100644 --- a/backend/src/routes/experiments.js +++ b/backend/src/routes/experiments.js @@ -349,8 +349,14 @@ router.get('/:id/daily-statuses', async (req, res, next) => { 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'); + const next = new Date(d); next.setUTCDate(next.getUTCDate() + 1); + where.date = { gte: d, lt: next }; + } const statuses = await prisma.dailyStatus.findMany({ - where: { animal: { experiment_id: req.params.id } }, + where, select: { id: true, animal_id: true, diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7b88139..2382bf5 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,6 +5,7 @@ import Dashboard from './pages/Dashboard'; import ExperimentDetail from './pages/ExperimentDetail'; import AnimalDetail from './pages/AnimalDetail'; import DailyStatusDetail from './pages/DailyStatusDetail'; +import ExperimentDayView from './pages/ExperimentDayView'; function Navbar() { return ( @@ -57,6 +58,7 @@ export default function App() { } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 121963c..f77e9d2 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -34,6 +34,7 @@ export const experimentsApi = { 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), + 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 dd3b6ba..afffe2d 100644 --- a/frontend/src/components/ExperimentCalendar.jsx +++ b/frontend/src/components/ExperimentCalendar.jsx @@ -3,152 +3,15 @@ import { format, startOfMonth, endOfMonth, eachDayOfInterval, getDay, addMonths, subMonths, } from 'date-fns'; -import { - ResponsiveContainer, BarChart, Bar, XAxis, YAxis, - CartesianGrid, Tooltip, Legend, -} from 'recharts'; -import { experimentsApi } from '../api/client'; -import Modal from './ui/Modal'; -import DailyStatusForm from './DailyStatusForm'; +import { useNavigate } from 'react-router-dom'; const BUILTIN_KEYS_SET = new Set(['experiment_description', 'vitals', 'treatment', 'notes']); -function SubjectReadOnly({ status, template }) { - const activeFields = template.filter((f) => f.active); - function getVal(s, f) { - if (f.builtin) return s[f.key]; - return s.custom_fields?.[f.fieldId]; - } - const nonEmpty = activeFields.filter((f) => { - const v = getVal(status, f); - return v !== null && v !== undefined && String(v).trim() !== ''; - }); - if (nonEmpty.length === 0) return

No field values recorded.

; - return ( -
- {nonEmpty.map((f) => ( -
- {f.label}:{' '} - {String(getVal(status, f))} -
- ))} -
- ); -} - -function DayPanel({ date, animals, allStatuses, template, experimentId, selectedField, fieldOptions, onStatusChange, onStatusCreate }) { - const dateStr = format(date, 'yyyy-MM-dd'); - const [editingAnimalId, setEditingAnimalId] = useState(null); - - const statusByAnimal = useMemo(() => { - const map = {}; - allStatuses.forEach((s) => { - if (s.date.slice(0, 10) === dateStr) map[s.animal_id] = s; - }); - return map; - }, [allStatuses, dateStr]); - - // Only show animals that have a status record for this day WITH a non-empty selected field - const animalsWithData = animals.filter((a) => { - const s = statusByAnimal[a.id]; - if (!s) return false; - if (!selectedField) return true; - const isBuiltin = BUILTIN_KEYS_SET.has(selectedField); - const val = isBuiltin ? s[selectedField] : s.custom_fields?.[selectedField]; - return val !== null && val !== undefined && String(val).trim() !== ''; - }); - - const fieldLabel = fieldOptions.find((f) => f.value === selectedField)?.label; - - // Bar chart: animals with analysis_summary on this day → Success/Failure/Total from that summary - const barData = useMemo(() => { - return animalsWithData - .map((animal) => { - const status = statusByAnimal[animal.id]; - const summary = status?.analysis_summary; - if (!summary || summary.total === 0) return null; - return { - name: animal.animal_name || animal.animal_id_string || animal.id.slice(0, 8), - total: summary.total, - success: summary.counts?.Success ?? 0, - failure: summary.counts?.Failure ?? 0, - }; - }) - .filter(Boolean); - }, [animalsWithData, statusByAnimal]); - - return ( -
- {animalsWithData.length === 0 && ( -

No data recorded for this day.

- )} - {/* Per-animal data for this day */} - {animalsWithData.map((animal) => { - const existing = statusByAnimal[animal.id]; - const isEditing = editingAnimalId === animal.id; - return ( -
-
-
- {animal.animal_name ?? 'Unknown subject'} - {animal.animal_id_string && ( - · {animal.animal_id_string} - )} -
- {!isEditing && ( - - )} -
- {isEditing ? ( - { onStatusChange(saved); setEditingAnimalId(null); }} - onCancel={() => setEditingAnimalId(null)} - /> - ) : ( - - )} -
- ); - })} - - {/* Bar chart: overall per-subject completeness */} - {barData.length > 0 && ( -
-

Session metrics

- - - - - - - - - - - - -
- )} -
- ); -} - -export default function ExperimentCalendar({ experimentId, template, allStatuses, animals, onStatusChange, onStatusCreate }) { +export default function ExperimentCalendar({ experimentId, template, allStatuses }) { + const navigate = useNavigate(); const [currentMonth, setCurrentMonth] = useState(() => new Date()); const [selectedField, setSelectedField] = useState(null); const [calendarDays, setCalendarDays] = useState({}); - const [loading, setLoading] = useState(false); - const [selectedDate, setSelectedDate] = useState(null); const fieldOptions = useMemo( () => template.filter((f) => f.active).map((f) => ({ value: f.builtin ? f.key : f.fieldId, label: f.label })), @@ -196,7 +59,6 @@ export default function ExperimentCalendar({ experimentId, template, allStatuses {fieldOptions.map((f) => )} {fieldOptions.length === 0 && } - {loading && Loading...}
@@ -224,7 +86,7 @@ export default function ExperimentCalendar({ experimentId, template, allStatuses key={dateStr} data-testid={`day-${dateStr}`} disabled={!hasData} - onClick={() => setSelectedDate(day)} + onClick={() => navigate(`/experiments/${experimentId}/day/${dateStr}${selectedField ? `?field=${selectedField}` : ''}`)} className={[ 'relative flex flex-col items-center justify-center rounded-lg py-1.5 min-h-[2.75rem] text-sm select-none transition-all', hasData ? 'bg-blue-50 border border-blue-200 hover:bg-blue-100 cursor-pointer' : 'text-gray-300 cursor-default', @@ -238,22 +100,6 @@ export default function ExperimentCalendar({ experimentId, template, allStatuses ); })}
- - {selectedDate && ( - setSelectedDate(null)} title={format(selectedDate, 'MMMM d, yyyy')} size="lg"> - { onStatusChange?.(updated); setSelectedDate(null); }} - onStatusCreate={(created) => { onStatusCreate?.(created); setSelectedDate(null); }} - /> - - )} ); } diff --git a/frontend/src/pages/ExperimentDayView.jsx b/frontend/src/pages/ExperimentDayView.jsx new file mode 100644 index 0000000..9b83ba7 --- /dev/null +++ b/frontend/src/pages/ExperimentDayView.jsx @@ -0,0 +1,253 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom'; +import { format } from 'date-fns'; +import { + ResponsiveContainer, BarChart, Bar, XAxis, YAxis, + CartesianGrid, Tooltip, Legend, +} from 'recharts'; +import { experimentsApi } from '../api/client'; +import Button from '../components/ui/Button'; +import Modal from '../components/ui/Modal'; +import DailyStatusForm from '../components/DailyStatusForm'; +import Alert from '../components/ui/Alert'; + +const BUILTIN_KEYS_SET = new Set(['experiment_description', 'vitals', 'treatment', 'notes']); + +function getFieldValue(status, field) { + if (field.builtin) return status[field.key]; + return status.custom_fields?.[field.fieldId]; +} + +function cellText(value) { + if (value === null || value === undefined || String(value).trim() === '') return null; + return String(value); +} + +export default function ExperimentDayView() { + const { id, date } = useParams(); // id = experimentId, date = YYYY-MM-DD + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + + const [experiment, setExperiment] = useState(null); + const [statuses, setStatuses] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [editStatus, setEditStatus] = useState(null); + + useEffect(() => { + setLoading(true); + Promise.all([ + experimentsApi.get(id), + experimentsApi.getDayStatuses(id, date), + ]) + .then(([exp, s]) => { + setExperiment(exp); + setStatuses(s); + }) + .catch((err) => setError(err.message ?? 'Failed to load')) + .finally(() => setLoading(false)); + }, [id, date]); + + const dailyTemplate = useMemo(() => { + if (!experiment) return []; + const t = experiment.template ?? []; + return t.filter((f) => f.active); + }, [experiment]); + + const animals = useMemo(() => experiment?.animals ?? [], [experiment]); + + // Selected field from URL (passed by calendar). Used to filter which rows show. + const selectedField = searchParams.get('field') ?? null; + + // Build map: animal_id → status for this day + const statusByAnimal = useMemo(() => { + const map = {}; + statuses.forEach((s) => { map[s.animal_id] = s; }); + return map; + }, [statuses]); + + // Rows: animals with a valid (non-empty) value for the selected field, or all if no field filter + const rows = useMemo(() => { + return animals + .map((animal) => ({ animal, status: statusByAnimal[animal.id] })) + .filter(({ status }) => { + if (!status) return false; + if (!selectedField) return true; + const isBuiltin = BUILTIN_KEYS_SET.has(selectedField); + const val = isBuiltin ? status[selectedField] : status.custom_fields?.[selectedField]; + return val !== null && val !== undefined && String(val).trim() !== ''; + }); + }, [animals, statusByAnimal, selectedField]); + + // Bar chart: analysis_summary from each row's status (same contract as calendar modal) + const barData = useMemo(() => { + return rows + .map(({ animal, status }) => { + const summary = status?.analysis_summary; + if (!summary || summary.total === 0) return null; + return { + name: animal.animal_name || animal.animal_id_string || animal.id.slice(0, 8), + total: summary.total, + success: summary.counts?.Success ?? 0, + failure: summary.counts?.Failure ?? 0, + }; + }) + .filter(Boolean); + }, [rows]); + + const displayDate = date + ? format(new Date(date + 'T12:00:00'), 'MMMM d, yyyy') + : date; + + if (loading) { + return ( +
Loading…
+ ); + } + if (error) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Breadcrumb */} + + +
+

{displayDate}

+ +
+ + {/* Status table */} + {rows.length === 0 ? ( +
+ No data recorded for this day. +
+ ) : ( +
+
+ + + + + {dailyTemplate.map((f) => { + const header = f.abbr + ? (f.unit ? `${f.abbr} (${f.unit})` : f.abbr) + : (f.unit ? `${f.label} (${f.unit})` : f.label); + return ( + + ); + })} + + + + {rows.map(({ animal, status }, i) => { + const rowBg = i % 2 === 0 ? 'bg-white' : 'bg-gray-50/40'; + return ( + + {/* Subject name → daily status detail */} + + {dailyTemplate.map((f) => ( + + ))} + + + ); + })} + +
+ Subject + + {header} + +
status && navigate(`/daily-statuses/${status.id}`)} + > + {animal.animal_name ?? animal.animal_id_string ?? animal.id.slice(0, 8)} + {animal.animal_id_string && animal.animal_name && ( + + {animal.animal_id_string} + + )} + + + {status ? (cellText(getFieldValue(status, f)) ?? ) : } + + + {status && ( + + )} +
+
+
+ )} + + {/* Session metrics bar chart */} + {barData.length > 0 && ( +
+

Session metrics

+ + + + + + + + + + + + +
+ )} + + {/* Edit modal */} + {editStatus && ( + setEditStatus(null)} + title="Edit daily status" + size="lg" + > + { + setStatuses((prev) => prev.map((s) => s.id === updated.id ? updated : s)); + setEditStatus(null); + }} + onCancel={() => setEditStatus(null)} + /> + + )} +
+ ); +} diff --git a/frontend/src/pages/ExperimentDetail.jsx b/frontend/src/pages/ExperimentDetail.jsx index ecab307..b5fd340 100644 --- a/frontend/src/pages/ExperimentDetail.jsx +++ b/frontend/src/pages/ExperimentDetail.jsx @@ -372,15 +372,6 @@ export default function ExperimentDetail() { experimentId={id} template={dailyTemplate} allStatuses={experimentStatuses} - animals={animals} - onStatusChange={(updated) => { - setExperimentStatuses((prev) => - prev.map((s) => (s.id === updated.id ? updated : s)) - ); - }} - onStatusCreate={(created) => { - setExperimentStatuses((prev) => [...prev, created]); - }} /> )} diff --git a/frontend/tests/ExperimentCalendar.test.jsx b/frontend/tests/ExperimentCalendar.test.jsx index ad9440d..b7351a8 100644 --- a/frontend/tests/ExperimentCalendar.test.jsx +++ b/frontend/tests/ExperimentCalendar.test.jsx @@ -1,36 +1,18 @@ import React from 'react'; -import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import ExperimentCalendar from '../src/components/ExperimentCalendar'; -import { experimentsApi } from '../src/api/client'; -jest.mock('../src/api/client', () => ({ - experimentsApi: { - getCalendar: jest.fn(), - }, -})); - -jest.mock('recharts', () => ({ - ResponsiveContainer: ({ children }) =>
{children}
, - BarChart: ({ children }) =>
{children}
, - Bar: () => null, - XAxis: () => null, - YAxis: () => null, - CartesianGrid: () => null, - Tooltip: () => null, - Legend: () => null, +const mockNavigate = jest.fn(); +jest.mock('react-router-dom', () => ({ + useNavigate: () => mockNavigate, })); const EXP_ID = 'aaaaaaaa-0000-0000-0000-000000000001'; const TEMPLATE = [ - { fieldId: '00000000-0000-0000-0000-000000000002', key: 'vitals', label: 'Vitals', active: true, builtin: true }, - { fieldId: 'ww000000-0000-0000-0000-000000000099', key: 'weights', label: 'Weights', active: true, builtin: false }, - { fieldId: 'zz000000-0000-0000-0000-000000000001', key: 'notes', label: 'Notes', active: false, builtin: true }, -]; - -const ANIMALS = [ - { id: 'a1000000-0000-0000-0000-000000000001', animal_name: 'Rat A', animal_id_string: 'R001' }, - { id: 'a2000000-0000-0000-0000-000000000002', animal_name: 'Rat B', animal_id_string: 'R002' }, + { fieldId: '00000000-0000-0000-0000-000000000002', key: 'vitals', label: 'Vitals', active: true, builtin: true }, + { fieldId: 'ww000000-0000-0000-0000-000000000099', key: 'weights', label: 'Weights', active: true, builtin: false }, + { fieldId: 'zz000000-0000-0000-0000-000000000001', key: 'notes', label: 'Notes', active: false, builtin: true }, ]; function todayStr() { @@ -38,7 +20,7 @@ function todayStr() { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } -function makeStatus(animalId, date, customFields = {}, vitals = null, analysisSummary = null) { +function makeStatus(animalId, date, customFields = {}, vitals = null) { return { id: `s-${animalId}-${date}`, animal_id: animalId, @@ -48,7 +30,7 @@ function makeStatus(animalId, date, customFields = {}, vitals = null, analysisSu treatment: null, notes: null, custom_fields: customFields, - analysis_summary: analysisSummary, + analysis_summary: null, }; } @@ -60,33 +42,25 @@ beforeEach(() => { describe('ExperimentCalendar rendering', () => { it('renders the current month and year', () => { - render( - , - ); + render(); const now = new Date(); const monthLabel = now.toLocaleString('en-US', { month: 'long', year: 'numeric' }); expect(screen.getByText(monthLabel)).toBeInTheDocument(); }); it('renders day-of-week headers', () => { - render( - , - ); + render(); for (const d of ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']) { expect(screen.getByText(d)).toBeInTheDocument(); } }); it('renders the field selector with active template fields only', () => { - render( - , - ); - expect(screen.getByTestId('field-select')).toBeInTheDocument(); - const options = screen.getAllByRole('option'); - const labels = options.map((o) => o.textContent); - expect(labels).toContain('Vitals'); - expect(labels).toContain('Weights'); - expect(labels).not.toContain('Notes'); // inactive + render(); + const options = screen.getAllByRole('option').map((o) => o.textContent); + expect(options).toContain('Vitals'); + expect(options).toContain('Weights'); + expect(options).not.toContain('Notes'); // inactive }); }); @@ -94,9 +68,7 @@ describe('ExperimentCalendar rendering', () => { describe('default field selection', () => { it('defaults to the field whose label contains "weight"', () => { - render( - , - ); + render(); expect(screen.getByTestId('field-select')).toHaveValue('ww000000-0000-0000-0000-000000000099'); }); @@ -105,9 +77,7 @@ describe('default field selection', () => { { 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 }, ]; - render( - , - ); + render(); expect(screen.getByTestId('field-select')).toHaveValue('treatment'); }); }); @@ -118,30 +88,33 @@ describe('count badges', () => { 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' }), + makeStatus('a1', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }), + makeStatus('a2', dateStr, { 'ww000000-0000-0000-0000-000000000099': '310' }), ]; - render( - , - ); + render(); expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('2'); }); it('does not show count badge on days with no data', () => { - const dateStr = todayStr(); - render( - , - ); - expect(screen.queryByTestId(`count-${dateStr}`)).not.toBeInTheDocument(); + render(); + expect(screen.queryByTestId(`count-${todayStr()}`)).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( - , - ); - // Switch to vitals field + const statuses = [makeStatus('a1', dateStr, {}, 'HR 72')]; + render(); + fireEvent.change(screen.getByTestId('field-select'), { target: { value: 'vitals' } }); + expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('1'); + }); + + it('recomputes counts after field change', () => { + const dateStr = todayStr(); + const statuses = [makeStatus('a1', dateStr, {}, 'HR 72')]; + render(); + // 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' } }); expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('1'); }); @@ -151,9 +124,7 @@ describe('count badges', () => { describe('month navigation', () => { it('moves to the previous month on ‹ click', () => { - render( - , - ); + render(); const now = new Date(); const prev = new Date(now.getFullYear(), now.getMonth() - 1, 1); const prevLabel = prev.toLocaleString('en-US', { month: 'long', year: 'numeric' }); @@ -162,172 +133,54 @@ describe('month navigation', () => { }); it('moves to the next month on › click', () => { - render( - , - ); + render(); 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')); expect(screen.getByText(nextLabel)).toBeInTheDocument(); }); - - 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( - , - ); - // 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' } }); - expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('1'); - }); }); -// ── Day click → modal ───────────────────────────────────────────────────────── +// ── Day click navigation ────────────────────────────────────────────────────── -describe('day click modal', () => { - it('opens a modal when a day with data is clicked', async () => { +describe('day click navigation', () => { + it('navigates to the day view with field param when a day with data is clicked', () => { const dateStr = todayStr(); const statuses = [ - makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }), + makeStatus('a1', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }), ]; - render( - , - ); + render(); fireEvent.click(screen.getByTestId(`day-${dateStr}`)); - await waitFor(() => { - const dialog = screen.getByRole('dialog'); - expect(within(dialog).getAllByText('Rat A').length).toBeGreaterThan(0); - }); + expect(mockNavigate).toHaveBeenCalledWith( + `/experiments/${EXP_ID}/day/${dateStr}?field=ww000000-0000-0000-0000-000000000099`, + ); }); - 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( - , - ); - 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( - , - ); - const dayBtn = screen.getByTestId(`day-${dateStr}`); + it('does not navigate when clicking a day without data (button is disabled)', () => { + render(); + const dayBtn = screen.getByTestId(`day-${todayStr()}`); expect(dayBtn).toBeDisabled(); fireEvent.click(dayBtn); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(mockNavigate).not.toHaveBeenCalled(); }); - it('shows only animals with data — excludes subjects with no record that day', async () => { + it('includes the currently selected field in the navigation URL', () => { const dateStr = todayStr(); - // Only Rat A has a record; Rat B does not - const statuses = [ - makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '320' }), - ]; - render( - , + const statuses = [makeStatus('a1', dateStr, {}, 'HR 72')]; + render(); + // Switch to vitals field first + fireEvent.change(screen.getByTestId('field-select'), { target: { value: 'vitals' } }); + fireEvent.click(screen.getByTestId(`day-${dateStr}`)); + expect(mockNavigate).toHaveBeenCalledWith( + `/experiments/${EXP_ID}/day/${dateStr}?field=vitals`, ); - fireEvent.click(screen.getByTestId(`day-${dateStr}`)); - await waitFor(() => { - const dialog = screen.getByRole('dialog'); - expect(within(dialog).getAllByText('Rat A').length).toBeGreaterThan(0); - expect(within(dialog).queryAllByText('Rat B')).toHaveLength(0); - }); - }); -}); - -// ── Bar chart (inside day modal) ────────────────────────────────────────────── -// -// The bar chart reads from status.analysis_summary — a pre-computed field on -// each DailyStatus record containing trial-level session metrics: -// { total: number, counts: { Success: number, Failure: number }, success_rate: number } -// -// X-axis: animals that have a valid (non-empty) value for the chosen calendar -// field on THAT specific day. Each day can show a different set of animals. -// Bars: Total / Success / Failure from analysis_summary for that animal on that day. -// -// Chart is hidden when no animal on that day has analysis_summary.total > 0. -// Do NOT compute success/failure from the field value itself — always use analysis_summary. - -describe('bar chart inside day modal', () => { - it('renders session metrics bar chart when at least one animal has analysis_summary', async () => { - const dateStr = todayStr(); - const summary = { total: 18, counts: { Success: 5, Failure: 13 }, success_rate: 0.28 }; - const statuses = [ - makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }, null, summary), - ]; - render(); - fireEvent.click(screen.getByTestId(`day-${dateStr}`)); - await waitFor(() => { - expect(within(screen.getByRole('dialog')).getByTestId('bar-chart')).toBeInTheDocument(); - }); - }); - - it('hides bar chart when no animal has analysis_summary (null or total=0)', async () => { - const dateStr = todayStr(); - const statuses = [ - // Has field data (so day is clickable) but no analysis_summary - makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }), - ]; - render(); - fireEvent.click(screen.getByTestId(`day-${dateStr}`)); - await waitFor(() => screen.getByRole('dialog')); - expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument(); - }); - - it('hides bar chart when analysis_summary exists but total is 0', async () => { - const dateStr = todayStr(); - const emptySummary = { total: 0, counts: { Success: 0, Failure: 0 }, success_rate: null }; - const statuses = [ - makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }, null, emptySummary), - ]; - render(); - fireEvent.click(screen.getByTestId(`day-${dateStr}`)); - await waitFor(() => screen.getByRole('dialog')); - expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument(); - }); - - it('shows bar chart for multiple animals each with their own analysis_summary', async () => { - const dateStr = todayStr(); - const summaryA = { total: 18, counts: { Success: 5, Failure: 13 }, success_rate: 0.28 }; - const summaryB = { total: 10, counts: { Success: 7, Failure: 3 }, success_rate: 0.7 }; - const statuses = [ - makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }, null, summaryA), - makeStatus('a2000000-0000-0000-0000-000000000002', dateStr, { 'ww000000-0000-0000-0000-000000000099': '310' }, null, summaryB), - ]; - render(); - fireEvent.click(screen.getByTestId(`day-${dateStr}`)); - await waitFor(() => { - expect(within(screen.getByRole('dialog')).getByTestId('bar-chart')).toBeInTheDocument(); - }); - }); - - it('excludes from chart animals whose field value is empty on that day, even with analysis_summary', async () => { - const dateStr = todayStr(); - const summary = { total: 10, counts: { Success: 7, Failure: 3 }, success_rate: 0.7 }; - const statuses = [ - // Rat A: has weight AND analysis_summary → appears in chart - makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }, null, summary), - // Rat B: has analysis_summary but NO weight → not in day view at all, not in chart - makeStatus('a2000000-0000-0000-0000-000000000002', dateStr, {}, null, summary), - ]; - render(); - fireEvent.click(screen.getByTestId(`day-${dateStr}`)); - await waitFor(() => screen.getByRole('dialog')); - // Chart shows because Rat A has data - expect(screen.getByTestId('bar-chart')).toBeInTheDocument(); - // Rat B not shown in day view (no weight value) - const dialog = screen.getByRole('dialog'); - expect(within(dialog).queryAllByText('Rat B')).toHaveLength(0); + }); + + it('navigates with no field param when selectedField is null (no active fields)', () => { + const emptyTemplate = []; + render(); + // No days have data with empty template, so nothing to click — just verify no crash + expect(screen.queryByTestId(`count-${todayStr()}`)).not.toBeInTheDocument(); }); });