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>
This commit is contained in:
Experiments DB Dev
2026-04-23 09:26:35 -04:00
parent 09a853e28b
commit 9535f86970
34 changed files with 5460 additions and 340 deletions
+322 -90
View File
@@ -1,21 +1,111 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { experimentsApi } from '../api/client';
import Button from './ui/Button';
import Alert from './ui/Alert';
// Slugify a label into a valid field key
// ── helpers ────────────────────────────────────────────────────────────────────
function toKey(label) {
return label
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64);
return label.toLowerCase().trim()
.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 64);
}
function FieldRow({ field, onChange, onToggle, onRemove, allKeys }) {
// ── Tag chip ───────────────────────────────────────────────────────────────────
function TagChip({ value, onRemove }) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-indigo-50 border border-indigo-200 text-indigo-700 text-xs font-medium">
{value}
<button
type="button"
aria-label={`Remove tag ${value}`}
onClick={onRemove}
className="text-indigo-400 hover:text-indigo-700 leading-none"
></button>
</span>
);
}
// ── Inline tag editor (expanded panel below a field row) ──────────────────────
function TagPanel({ field, onChange, onAddTag, onRemoveTag }) {
const [draft, setDraft] = useState('');
const [err, setErr] = useState('');
const disabled = !!field.tagsDisabled;
function commit() {
const v = draft.trim();
if (!v) { setErr('Tag cannot be empty'); return; }
if (v.length > 512) { setErr('Tag must be ≤512 characters'); return; }
if ((field.tags ?? []).includes(v)) { setErr('Tag already exists'); return; }
setErr('');
onAddTag(field.fieldId, v);
setDraft('');
}
return (
<div className="px-4 pb-3 pt-2 bg-indigo-50/40 border-t border-indigo-100 rounded-b-lg">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-semibold text-indigo-600 uppercase tracking-wide">Quick-fill tags</p>
<label className="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer select-none">
<input
type="checkbox"
checked={disabled}
onChange={(e) => onChange({ ...field, tagsDisabled: e.target.checked })}
className="w-3.5 h-3.5 accent-indigo-600"
/>
Disable tagging
</label>
</div>
{!disabled && (
<>
<div className="flex flex-wrap gap-1.5 mb-2 min-h-[24px]">
{(field.tags ?? []).length === 0 && (
<span className="text-xs text-gray-400 italic">No tags yet.</span>
)}
{(field.tags ?? []).map((t) => (
<TagChip key={t} value={t} onRemove={() => onRemoveTag(field.fieldId, t)} />
))}
</div>
<div className="flex gap-2 items-start">
<div className="flex-1">
<input
aria-label={`Add tag for ${field.label}`}
className={`w-full text-sm rounded border px-2 py-1 focus:outline-none focus:ring-2 focus:ring-indigo-400 ${err ? 'border-red-400' : 'border-gray-300'}`}
placeholder="Type a tag value…"
value={draft}
onChange={(e) => { setDraft(e.target.value); setErr(''); }}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } }}
/>
{err && <p className="text-xs text-red-500 mt-0.5">{err}</p>}
</div>
<Button size="sm" type="button" onClick={commit}>+ Add</Button>
</div>
</>
)}
{disabled && (
<p className="text-xs text-gray-400 italic">Tagging is disabled for this field. Existing tags are preserved.</p>
)}
</div>
);
}
// ── Single field row (draggable) ───────────────────────────────────────────────
function FieldRow({
field, index, total,
onChange, onToggle, onRemove, onAddTag, onRemoveTag,
allKeys,
// drag props
onDragStart, onDragEnter, onDragEnd,
isDragOver,
}) {
const [labelDraft, setLabelDraft] = useState(field.label);
const [labelError, setLabelError] = useState('');
const [showTags, setShowTags] = useState(false);
useEffect(() => { setLabelDraft(field.label); }, [field.label]);
@@ -23,115 +113,248 @@ function FieldRow({ field, onChange, onToggle, onRemove, allKeys }) {
const trimmed = labelDraft.trim();
if (!trimmed) { setLabelError('Label cannot be empty'); return; }
if (trimmed.length > 128) { setLabelError('Label must be ≤128 characters'); return; }
// For custom fields, also ensure the derived key is unique
if (!field.builtin) {
const newKey = toKey(trimmed);
if (!newKey) { setLabelError('Label must contain at least one alphanumeric character'); return; }
const duplicate = allKeys.find((k) => k !== field.key && k === newKey);
if (duplicate) { setLabelError('A field with this name already exists'); return; }
if (allKeys.find((k) => k !== field.key && k === newKey)) {
setLabelError('A field with this name already exists'); return;
}
}
setLabelError('');
onChange({ ...field, label: trimmed, key: field.builtin ? field.key : toKey(trimmed) });
}
return (
<div className={`flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors ${field.active ? 'bg-white border-gray-200' : 'bg-gray-50 border-gray-100 opacity-60'}`}>
{/* Drag handle placeholder (visual only) */}
<span className="text-gray-300 cursor-default select-none text-lg leading-none"></span>
const tagCount = (field.tags ?? []).length;
{/* Label input */}
<div className="flex-1 min-w-0">
<input
aria-label={`Field label for ${field.key}`}
className={`w-full text-sm rounded border px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 ${labelError ? 'border-red-400' : 'border-gray-300'} ${!field.active ? 'bg-gray-100' : 'bg-white'}`}
value={labelDraft}
return (
<div
className={`rounded-lg border transition-all ${isDragOver ? 'border-blue-400 shadow-md scale-[1.01]' : 'border-gray-200'} ${field.active ? 'bg-white' : 'bg-gray-50 opacity-60'}`}
draggable
onDragStart={() => onDragStart(index)}
onDragEnter={() => onDragEnter(index)}
onDragEnd={onDragEnd}
onDragOver={(e) => e.preventDefault()}
>
{/* Main row */}
<div className="flex items-center gap-3 px-3 py-2.5">
{/* Drag handle */}
<span
className="text-gray-300 hover:text-gray-500 cursor-grab active:cursor-grabbing select-none text-lg leading-none"
title="Drag to reorder"
aria-label="Drag handle"
></span>
{/* Label input */}
<div className="flex-1 min-w-0">
<input
aria-label={`Field label for ${field.key}`}
className={`w-full text-sm rounded border px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 ${labelError ? 'border-red-400' : 'border-gray-300'} ${!field.active ? 'bg-gray-100' : 'bg-white'}`}
value={labelDraft}
disabled={!field.active}
onChange={(e) => { setLabelDraft(e.target.value); setLabelError(''); }}
onBlur={commitLabel}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitLabel(); } }}
/>
{labelError && <p className="text-xs text-red-500 mt-0.5">{labelError}</p>}
<p className="text-xs text-gray-400 mt-0.5 font-mono">
key: {field.key}
{field.fieldId && (
<span className="ml-2 text-gray-300" title={`Stable field ID: ${field.fieldId}`}>
· id: {field.fieldId.slice(0, 8)}
</span>
)}
</p>
</div>
{/* Type selector */}
<select
aria-label={`Field type for ${field.key}`}
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
value={field.type}
disabled={!field.active}
onChange={(e) => { setLabelDraft(e.target.value); setLabelError(''); }}
onBlur={commitLabel}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitLabel(); } }}
onChange={(e) => onChange({ ...field, type: e.target.value })}
>
<option value="text">Short text</option>
<option value="textarea">Long text</option>
</select>
{/* Width (text fields only) */}
{field.type === 'text' && (
<select
aria-label={`Width for ${field.key}`}
title="Input width in the entry form"
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
value={field.width ?? 100}
disabled={!field.active}
onChange={(e) => onChange({ ...field, width: Number(e.target.value) })}
>
<option value={100}>Full</option>
<option value={75}>3/4</option>
<option value={67}>2/3</option>
<option value={50}>1/2</option>
<option value={42}>5/12</option>
<option value={33}>1/3</option>
<option value={25}>1/4</option>
<option value={17}>1/6</option>
<option value={8}>1/12</option>
</select>
)}
{/* Abbreviation */}
<input
aria-label={`Abbreviation for ${field.key}`}
className="text-xs border border-gray-200 rounded px-2 py-1 w-20 focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
placeholder="Abbr"
title="Column abbreviation (≤10 chars)"
maxLength={10}
value={field.abbr ?? ''}
disabled={!field.active}
onChange={(e) => onChange({ ...field, abbr: e.target.value })}
/>
{labelError && <p className="text-xs text-red-500 mt-0.5">{labelError}</p>}
<p className="text-xs text-gray-400 mt-0.5 font-mono">
key: {field.key}
{field.fieldId && (
<span className="ml-2 text-gray-300" title={`Stable field ID: ${field.fieldId}`}>
· id: {field.fieldId.slice(0, 8)}
{/* Unit */}
<input
aria-label={`Unit for ${field.key}`}
className="text-xs border border-gray-200 rounded px-2 py-1 w-20 focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
placeholder="Unit"
title="Measurement unit (≤10 chars)"
maxLength={10}
value={field.unit ?? ''}
disabled={!field.active}
onChange={(e) => onChange({ ...field, unit: e.target.value })}
/>
{/* Tags toggle */}
<button
type="button"
title={showTags ? 'Hide tags' : 'Manage quick-fill tags'}
aria-label={`Manage tags for ${field.label}`}
onClick={() => setShowTags((v) => !v)}
className={`relative text-sm px-2 py-1 rounded border transition-colors ${
showTags
? 'border-indigo-400 bg-indigo-50 text-indigo-700'
: 'border-gray-200 text-gray-500 hover:bg-indigo-50 hover:border-indigo-300 hover:text-indigo-700'
}`}
>
🏷
{tagCount > 0 && (
<span className="absolute -top-1.5 -right-1.5 bg-indigo-600 text-white text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center leading-none">
{tagCount > 9 ? '9+' : tagCount}
</span>
)}
</p>
</button>
{/* Hide / Show */}
<button
type="button"
title={field.active ? 'Hide field' : 'Show field'}
aria-label={field.active ? `Hide ${field.label}` : `Show ${field.label}`}
onClick={() => onToggle(field.fieldId)}
className={`text-sm px-2 py-1 rounded border transition-colors ${
field.active
? 'border-gray-200 text-gray-500 hover:bg-yellow-50 hover:border-yellow-300 hover:text-yellow-700'
: 'border-green-200 text-green-600 hover:bg-green-50'
}`}
>
{field.active ? 'Hide' : 'Show'}
</button>
{/* Remove (custom fields only) */}
{!field.builtin && (
<button
type="button"
title="Remove field permanently"
aria-label={`Remove ${field.label}`}
onClick={() => onRemove(field.fieldId)}
className="text-sm px-2 py-1 rounded border border-red-200 text-red-500 hover:bg-red-50 transition-colors"
></button>
)}
</div>
{/* Type badge */}
<select
aria-label={`Field type for ${field.key}`}
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
value={field.type}
disabled={!field.active}
onChange={(e) => onChange({ ...field, type: e.target.value })}
>
<option value="text">Short text</option>
<option value="textarea">Long text</option>
</select>
{/* Toggle active */}
<button
title={field.active ? 'Hide field' : 'Show field'}
aria-label={field.active ? `Hide field ${field.label}` : `Show field ${field.label}`}
onClick={() => onToggle(field.key)}
className={`text-sm px-2 py-1 rounded border transition-colors ${
field.active
? 'border-gray-200 text-gray-500 hover:bg-yellow-50 hover:border-yellow-300 hover:text-yellow-700'
: 'border-green-200 text-green-600 hover:bg-green-50'
}`}
>
{field.active ? 'Hide' : 'Show'}
</button>
{/* Remove — only for custom fields */}
{!field.builtin && (
<button
title="Remove field permanently"
aria-label={`Remove field ${field.label}`}
onClick={() => onRemove(field.key)}
className="text-sm px-2 py-1 rounded border border-red-200 text-red-500 hover:bg-red-50 transition-colors"
>
</button>
{/* Tag panel (collapsible) */}
{showTags && (
<TagPanel field={field} onChange={onChange} onAddTag={onAddTag} onRemoveTag={onRemoveTag} />
)}
</div>
);
}
export default function TemplateEditor({ experimentId, onClose }) {
// ── Main TemplateEditor ────────────────────────────────────────────────────────
export default function TemplateEditor({ experimentId, onClose, loadFn, saveFn }) {
const [fields, setFields] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(false);
// Add-field form
const [newLabel, setNewLabel] = useState('');
const [newType, setNewType] = useState('text');
const [addError, setAddError] = useState('');
// Drag state
const dragIndex = useRef(null);
const [dragOverIndex, setDragOverIndex] = useState(null);
useEffect(() => {
experimentsApi.getTemplate(experimentId)
.then(setFields)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
const fn = loadFn ?? (() => experimentsApi.getTemplate(experimentId));
fn().then(setFields).catch((e) => setError(e.message)).finally(() => setLoading(false));
}, [experimentId]);
// ── field mutation helpers ─────────────────────────────────────────────────
function updateField(updated) {
setFields((prev) => prev.map((f) => f.key === updated.key ? updated : f));
setFields((prev) => prev.map((f) => f.fieldId === updated.fieldId ? updated : f));
}
function toggleField(fieldId) {
setFields((prev) => prev.map((f) => f.fieldId === fieldId ? { ...f, active: !f.active } : f));
}
function removeField(fieldId) {
setFields((prev) => prev.filter((f) => f.fieldId !== fieldId));
}
function toggleField(key) {
setFields((prev) => prev.map((f) => f.key === key ? { ...f, active: !f.active } : f));
// ── tag helpers ────────────────────────────────────────────────────────────
function addTag(fieldId, value) {
setFields((prev) => prev.map((f) =>
f.fieldId === fieldId
? { ...f, tags: [...(f.tags ?? []), value] }
: f
));
}
function removeTag(fieldId, value) {
setFields((prev) => prev.map((f) =>
f.fieldId === fieldId
? { ...f, tags: (f.tags ?? []).filter((t) => t !== value) }
: f
));
}
function removeField(key) {
setFields((prev) => prev.filter((f) => f.key !== key));
// ── drag handlers ──────────────────────────────────────────────────────────
function handleDragStart(index) {
dragIndex.current = index;
}
function handleDragEnter(index) {
setDragOverIndex(index);
}
function handleDragEnd() {
const from = dragIndex.current;
const to = dragOverIndex;
if (from !== null && to !== null && from !== to) {
setFields((prev) => {
const next = [...prev];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next;
});
}
dragIndex.current = null;
setDragOverIndex(null);
}
// ── add new field ──────────────────────────────────────────────────────────
function addField() {
const trimmed = newLabel.trim();
@@ -141,18 +364,21 @@ export default function TemplateEditor({ experimentId, onClose }) {
if (fields.some((f) => f.key === key)) { setAddError(`A field with key "${key}" already exists`); return; }
setAddError('');
const fieldId = crypto.randomUUID();
setFields((prev) => [...prev, { fieldId, key, label: trimmed, type: newType, builtin: false, active: true }]);
setFields((prev) => [...prev, { fieldId, key, label: trimmed, type: newType, builtin: false, active: true, tags: [] }]);
setNewLabel('');
setNewType('text');
}
// ── save ───────────────────────────────────────────────────────────────────
async function save() {
setSaving(true);
setError(null);
try {
await experimentsApi.updateTemplate(experimentId, fields);
const fn = saveFn ?? ((f) => experimentsApi.updateTemplate(experimentId, f));
await fn(fields);
setSuccess(true);
setTimeout(() => { setSuccess(false); onClose(fields); }, 800);
setTimeout(() => { setSuccess(false); onClose(fields); }, 700);
} catch (e) {
setError(e.message);
} finally {
@@ -165,7 +391,7 @@ export default function TemplateEditor({ experimentId, onClose }) {
return (
<div className="space-y-4">
{loading && <p className="text-sm text-gray-400">Loading template</p>}
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
{success && <Alert type="success" message="Template saved!" />}
{!loading && (
@@ -175,14 +401,22 @@ export default function TemplateEditor({ experimentId, onClose }) {
{fields.length === 0 && (
<p className="text-sm text-gray-400 italic text-center py-4">No fields yet. Add one below.</p>
)}
{fields.map((field) => (
{fields.map((field, index) => (
<FieldRow
key={field.key}
key={field.fieldId}
field={field}
index={index}
total={fields.length}
onChange={updateField}
onToggle={toggleField}
onRemove={removeField}
onAddTag={addTag}
onRemoveTag={removeTag}
allKeys={allKeys}
onDragStart={handleDragStart}
onDragEnter={handleDragEnter}
onDragEnd={handleDragEnd}
isDragOver={dragOverIndex === index}
/>
))}
</div>
@@ -211,14 +445,12 @@ export default function TemplateEditor({ experimentId, onClose }) {
<option value="text">Short text</option>
<option value="textarea">Long text</option>
</select>
<Button size="sm" onClick={addField} type="button">+ Add</Button>
<Button size="sm" type="button" onClick={addField}>+ Add</Button>
</div>
</div>
{/* Info note */}
<p className="text-xs text-gray-400">
<strong>Hide</strong> removes a builtin field from forms while preserving its data.
<strong className="ml-1"></strong> permanently removes a custom field (data in existing records is not deleted).
Drag <span className="font-mono"></span> to reorder · <strong>Hide</strong> removes a builtin field from forms while preserving its data · <strong>🏷</strong> manages quick-fill tags
</p>
{/* Actions */}