Files
experiments-database/frontend/src/App.jsx
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

69 lines
2.7 KiB
React

import React, { useEffect, useState } from 'react';
import { Routes, Route, Link, useLocation } from 'react-router-dom';
import { systemApi } from './api/client';
import Dashboard from './pages/Dashboard';
import ExperimentDetail from './pages/ExperimentDetail';
import AnimalDetail from './pages/AnimalDetail';
import DailyStatusDetail from './pages/DailyStatusDetail';
function Navbar() {
return (
<header className="bg-white border-b border-gray-200 sticky top-0 z-40">
<div className="max-w-5xl mx-auto px-4 h-14 flex items-center justify-between">
<Link to="/" className="flex items-center gap-2 font-bold text-gray-900 hover:text-blue-700 transition-colors">
<svg className="w-6 h-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
Animal Experiments DB
</Link>
<nav className="text-sm text-gray-500">
<Link to="/" className="hover:text-blue-600 transition-colors">Dashboard</Link>
</nav>
</div>
</header>
);
}
function NotFound() {
return (
<div className="max-w-5xl mx-auto px-4 py-20 text-center">
<h1 className="text-4xl font-bold text-gray-300 mb-4">404</h1>
<p className="text-gray-500 mb-6">Page not found.</p>
<Link to="/" className="text-blue-600 hover:underline"> Return to Dashboard</Link>
</div>
);
}
function Footer() {
const [dbTimezone, setDbTimezone] = useState(null);
useEffect(() => {
systemApi.info().then((d) => setDbTimezone(d.dbTimezone)).catch(() => {});
}, []);
return (
<footer className="border-t border-gray-200 mt-12 py-3">
<div className="max-w-5xl mx-auto px-4 text-xs text-gray-400 text-right">
Database timezone: <span className="font-mono">{dbTimezone ?? '…'}</span>
</div>
</footer>
);
}
export default function App() {
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
<Navbar />
<main className="flex-1">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/experiments/:id" element={<ExperimentDetail />} />
<Route path="/animals/:id" element={<AnimalDetail />} />
<Route path="/daily-statuses/:id" element={<DailyStatusDetail />} />
<Route path="*" element={<NotFound />} />
</Routes>
</main>
<Footer />
</div>
);
}