9535f86970
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>
120 lines
4.6 KiB
React
120 lines
4.6 KiB
React
import React, { useEffect, useState } from 'react';
|
|
import { auditLogsApi } from '../api/client';
|
|
import { format } from 'date-fns';
|
|
|
|
function DiffView({ changes }) {
|
|
const { before, after } = changes || {};
|
|
if (!before && after) {
|
|
return <span className="text-green-700 text-xs">Created: {JSON.stringify(after, null, 2)}</span>;
|
|
}
|
|
if (before && !after) {
|
|
return <span className="text-red-700 text-xs">Deleted record</span>;
|
|
}
|
|
if (before && after) {
|
|
const keys = Object.keys({ ...before, ...after }).filter(
|
|
(k) => JSON.stringify(before[k]) !== JSON.stringify(after[k])
|
|
);
|
|
if (keys.length === 0) return <span className="text-gray-400 text-xs">No field changes detected</span>;
|
|
return (
|
|
<dl className="text-xs space-y-1">
|
|
{keys.map((k) => (
|
|
<div key={k} className="grid grid-cols-3 gap-2">
|
|
<dt className="font-medium text-gray-600">{k}</dt>
|
|
<dd className="text-red-600 line-through col-span-1 truncate">{String(before[k] ?? '')}</dd>
|
|
<dd className="text-green-700 col-span-1 truncate">{String(after[k] ?? '')}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const ACTION_BADGE = {
|
|
CREATE: 'bg-green-100 text-green-800',
|
|
UPDATE: 'bg-yellow-100 text-yellow-800',
|
|
DELETE: 'bg-red-100 text-red-800',
|
|
};
|
|
|
|
export default function AuditLogSection({ tableName, recordId, limit = 50 }) {
|
|
const [logs, setLogs] = useState([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
const [expanded, setExpanded] = useState(null);
|
|
|
|
useEffect(() => {
|
|
setLoading(true);
|
|
auditLogsApi
|
|
.list({ tableName, recordId, limit })
|
|
.then(({ logs, total }) => {
|
|
setLogs(logs);
|
|
setTotal(total);
|
|
})
|
|
.catch((e) => setError(e.message))
|
|
.finally(() => setLoading(false));
|
|
}, [tableName, recordId, limit]);
|
|
|
|
return (
|
|
<section aria-label="Modification History" className="mt-8">
|
|
<h3 className="text-base font-semibold text-gray-800 mb-3 flex items-center gap-2">
|
|
<svg className="w-4 h-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
Modification History
|
|
{total > 0 && (
|
|
<span className="text-xs font-normal text-gray-500">({total} entries)</span>
|
|
)}
|
|
</h3>
|
|
|
|
{loading && <p className="text-sm text-gray-500">Loading history…</p>}
|
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
|
{!loading && !error && logs.length === 0 && (
|
|
<p className="text-sm text-gray-400 italic">No modification history yet.</p>
|
|
)}
|
|
|
|
{logs.length > 0 && (
|
|
<div className="border border-gray-200 rounded-lg overflow-hidden divide-y divide-gray-100">
|
|
{logs.slice(0, limit).map((log) => (
|
|
<div key={log.id} className="bg-white">
|
|
<button
|
|
className="w-full text-left px-4 py-3 flex items-center gap-3 hover:bg-gray-50 transition-colors"
|
|
onClick={() => setExpanded(expanded === log.id ? null : log.id)}
|
|
aria-expanded={expanded === log.id}
|
|
>
|
|
<span className={`shrink-0 px-2 py-0.5 rounded text-xs font-semibold ${ACTION_BADGE[log.action] || 'bg-gray-100 text-gray-700'}`}>
|
|
{log.action}
|
|
</span>
|
|
<span className="text-xs text-gray-500 shrink-0">
|
|
{format(new Date(log.timestamp), 'yyyy-MM-dd HH:mm:ss')}
|
|
</span>
|
|
<span className="text-xs text-gray-400 truncate flex-1">
|
|
{log.table_name} / {log.record_id}
|
|
</span>
|
|
<svg
|
|
className={`w-4 h-4 text-gray-400 shrink-0 transition-transform ${expanded === log.id ? 'rotate-180' : ''}`}
|
|
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
{expanded === log.id && (
|
|
<div className="px-4 pb-3 bg-gray-50 border-t border-gray-100">
|
|
<DiffView changes={log.changes} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{total > limit && (
|
|
<p className="mt-2 text-xs text-gray-400">
|
|
Showing {limit} of {total} entries — open the daily status page to see the full history.
|
|
</p>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|