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:
@@ -0,0 +1,343 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
|
||||
// ── Colors ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const BASE_COLORS = {
|
||||
Success: { bg: 'bg-green-100', border: 'border-green-300', text: 'text-green-800', dot: 'bg-green-500', badge: 'bg-green-500' },
|
||||
Failure: { bg: 'bg-red-100', border: 'border-red-300', text: 'text-red-800', dot: 'bg-red-500', badge: 'bg-red-500' },
|
||||
Other: { bg: 'bg-gray-100', border: 'border-gray-300', text: 'text-gray-700', dot: 'bg-gray-400', badge: 'bg-gray-400' },
|
||||
};
|
||||
const EXTRA_COLORS = [
|
||||
{ bg: 'bg-indigo-100', border: 'border-indigo-300', text: 'text-indigo-800', dot: 'bg-indigo-500', badge: 'bg-indigo-500' },
|
||||
{ bg: 'bg-yellow-100', border: 'border-yellow-300', text: 'text-yellow-800', dot: 'bg-yellow-500', badge: 'bg-yellow-500' },
|
||||
{ bg: 'bg-purple-100', border: 'border-purple-300', text: 'text-purple-800', dot: 'bg-purple-500', badge: 'bg-purple-500' },
|
||||
{ bg: 'bg-pink-100', border: 'border-pink-300', text: 'text-pink-800', dot: 'bg-pink-500', badge: 'bg-pink-500' },
|
||||
];
|
||||
function getColors(cat, customCats = []) {
|
||||
if (BASE_COLORS[cat]) return BASE_COLORS[cat];
|
||||
return EXTRA_COLORS[Math.max(0, customCats.indexOf(cat)) % EXTRA_COLORS.length];
|
||||
}
|
||||
|
||||
const ORDINALS = ['1st', '2nd', '3rd', '4th', '5th'];
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Build display items from a sequence slice, respecting mode + collapsibility. */
|
||||
function buildItems(seqSlice, mode, collapsible) {
|
||||
const categorised = seqSlice.filter(s => s.category);
|
||||
if (mode === 'discrete') {
|
||||
return categorised.map((s, i) => ({ id: i, category: s.category, count: 1 }));
|
||||
}
|
||||
// Rebuild runs within this slice, then apply collapsibility
|
||||
const runs = [];
|
||||
for (const s of categorised) {
|
||||
if (runs.length === 0 || runs[runs.length - 1].category !== s.category) {
|
||||
runs.push({ category: s.category, length: 1 });
|
||||
} else {
|
||||
runs[runs.length - 1].length++;
|
||||
}
|
||||
}
|
||||
const items = [];
|
||||
for (const run of runs) {
|
||||
if (collapsible[run.category] === false) {
|
||||
for (let i = 0; i < run.length; i++) items.push({ id: items.length, category: run.category, count: 1 });
|
||||
} else {
|
||||
items.push({ id: items.length, category: run.category, count: run.length });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Compute per-category counts and total for a sequence slice. */
|
||||
function segStats(seqSlice) {
|
||||
const byCat = {};
|
||||
for (const s of seqSlice) {
|
||||
if (!s.category) continue;
|
||||
byCat[s.category] = (byCat[s.category] || 0) + 1;
|
||||
}
|
||||
const total = Object.values(byCat).reduce((a, b) => a + b, 0);
|
||||
return { byCat, total };
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Props:
|
||||
* runs {category, length}[] — global run-length-encoded sequence
|
||||
* sequence {note, category, timestamp}[]
|
||||
* customCategories string[]
|
||||
*/
|
||||
export default function RunSequenceView({ runs = [], sequence = [], customCategories = [], sessionEndTs = null }) {
|
||||
const [mode, setMode] = useState('grouped');
|
||||
const [collapsible, setCollapsible] = useState({});
|
||||
const [splitN, setSplitN] = useState(1);
|
||||
const [durationMins, setDurationMins] = useState(20);
|
||||
const [selectedSegment, setSelectedSegment] = useState(null);
|
||||
|
||||
// Initialise collapsibility for any new categories
|
||||
const allCats = useMemo(() => [...new Set(runs.map(r => r.category))], [runs]);
|
||||
useEffect(() => {
|
||||
setCollapsible(prev => {
|
||||
const next = { ...prev };
|
||||
for (const cat of allCats) {
|
||||
if (!(cat in next)) next[cat] = cat !== 'Success';
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [allCats]);
|
||||
|
||||
// Reset selected segment when split changes
|
||||
useEffect(() => { setSelectedSegment(null); }, [splitN]);
|
||||
|
||||
// ── Timestamp bounds ─────────────────────────────────────────────────────────
|
||||
|
||||
const { startTs, segSize, windowUnderrun } = useMemo(() => {
|
||||
let endT = sessionEndTs != null ? sessionEndTs : null;
|
||||
if (endT === null) {
|
||||
const ts = sequence.map(s => parseFloat(s.timestamp)).filter(t => !isNaN(t));
|
||||
endT = ts.length ? Math.max(...ts) : durationMins * 60;
|
||||
}
|
||||
const rawStart = endT - durationMins * 60;
|
||||
return {
|
||||
startTs: rawStart,
|
||||
segSize: (durationMins * 60) / Math.max(splitN, 1),
|
||||
windowUnderrun: rawStart < 0 ? Math.abs(rawStart) : 0,
|
||||
};
|
||||
}, [sequence, sessionEndTs, durationMins, splitN]);
|
||||
|
||||
function segIndexOf(timestamp) {
|
||||
const t = parseFloat(timestamp);
|
||||
if (isNaN(t)) return 0;
|
||||
return Math.min(Math.max(Math.floor((t - startTs) / segSize), 0), splitN - 1);
|
||||
}
|
||||
|
||||
// ── Segment data (only when split > 1) ───────────────────────────────────────
|
||||
|
||||
const segmentData = useMemo(() => {
|
||||
if (splitN === 1) return null;
|
||||
const groups = Array.from({ length: splitN }, () => []);
|
||||
for (const s of sequence) {
|
||||
groups[segIndexOf(s.timestamp)].push(s);
|
||||
}
|
||||
return groups.map((grp, i) => ({
|
||||
index: i,
|
||||
items: buildItems(grp, mode, collapsible),
|
||||
stats: segStats(grp),
|
||||
}));
|
||||
}, [splitN, sequence, mode, collapsible, startTs, segSize]);
|
||||
|
||||
// ── Single-segment display items (split = 1) ─────────────────────────────────
|
||||
|
||||
const singleItems = useMemo(() => {
|
||||
if (splitN !== 1) return [];
|
||||
if (mode === 'discrete') {
|
||||
const src = sequence.length > 0
|
||||
? sequence.filter(s => s.category)
|
||||
: runs.flatMap(r => Array.from({ length: r.length }, () => ({ category: r.category })));
|
||||
return src.map((s, i) => ({ id: i, category: s.category, count: 1 }));
|
||||
}
|
||||
const items = [];
|
||||
for (const run of runs) {
|
||||
if (collapsible[run.category] === false) {
|
||||
for (let i = 0; i < run.length; i++) items.push({ id: items.length, category: run.category, count: 1 });
|
||||
} else {
|
||||
items.push({ id: items.length, category: run.category, count: run.length });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [splitN, mode, runs, sequence, collapsible]);
|
||||
|
||||
// ── Global totals ─────────────────────────────────────────────────────────────
|
||||
|
||||
const globalStats = useMemo(() => segStats(sequence), [sequence]);
|
||||
|
||||
if (runs.length === 0) return null;
|
||||
|
||||
const hasTimestamps = sequence.some(s => !isNaN(parseFloat(s.timestamp)));
|
||||
const activeSegStats = selectedSegment !== null && segmentData ? segmentData[selectedSegment]?.stats ?? null : null;
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* ── Controls row ── */}
|
||||
<div className="flex items-center flex-wrap gap-x-4 gap-y-1.5 mb-2">
|
||||
{/* Title */}
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Run sequence ({runs.length} runs)
|
||||
</p>
|
||||
|
||||
{/* Mode toggle */}
|
||||
<div className="flex rounded border border-gray-200 overflow-hidden text-xs">
|
||||
<button type="button" onClick={() => setMode('discrete')}
|
||||
className={`px-2 py-0.5 transition-colors ${mode === 'discrete' ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
|
||||
Discrete
|
||||
</button>
|
||||
<button type="button" onClick={() => setMode('grouped')}
|
||||
className={`px-2 py-0.5 border-l border-gray-200 transition-colors ${mode === 'grouped' ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
|
||||
Grouped
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Time split */}
|
||||
{hasTimestamps && (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-gray-400">Split:</span>
|
||||
<select value={splitN} onChange={e => setSplitN(Number(e.target.value))}
|
||||
className="border border-gray-200 rounded px-1.5 py-0.5 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400">
|
||||
<option value={1}>None</option>
|
||||
<option value={2}>½</option>
|
||||
<option value={3}>⅓</option>
|
||||
<option value={4}>¼</option>
|
||||
<option value={5}>⅕</option>
|
||||
</select>
|
||||
<input
|
||||
type="number" min={1} max={600} value={durationMins}
|
||||
onChange={e => setDurationMins(Math.max(1, Number(e.target.value)))}
|
||||
className="border border-gray-200 rounded px-1.5 py-0.5 w-14 text-xs text-right focus:outline-none focus:ring-1 focus:ring-indigo-400"
|
||||
title="Experiment duration in minutes"
|
||||
/>
|
||||
<span className="text-gray-400">min</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Per-category collapse toggles (grouped mode) ── */}
|
||||
{mode === 'grouped' && allCats.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{allCats.map(cat => {
|
||||
const c = getColors(cat, customCategories);
|
||||
const isGrouped = collapsible[cat] !== false;
|
||||
return (
|
||||
<button key={cat} type="button"
|
||||
onClick={() => setCollapsible(prev => ({ ...prev, [cat]: !prev[cat] }))}
|
||||
title={isGrouped ? 'Grouped — click for discrete' : 'Discrete — click to group'}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs border transition-colors
|
||||
${isGrouped ? `${c.bg} ${c.border} ${c.text}` : `bg-white ${c.border} ${c.text} opacity-70`}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
|
||||
{cat}: {isGrouped ? 'grouped' : 'discrete'}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Underrun warning ── */}
|
||||
{splitN > 1 && windowUnderrun > 0 && (() => {
|
||||
const u = Math.round(windowUnderrun);
|
||||
const m = Math.floor(u / 60), s = u % 60;
|
||||
return (
|
||||
<p className="text-[10px] text-amber-600 bg-amber-50 border border-amber-200 rounded px-2 py-1 mb-2">
|
||||
The {durationMins}-min window starts {m}:{String(s).padStart(2,'0')} before the recording — the 1st segment includes unrecorded time.
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* ── Segment buttons (when split > 1) ── */}
|
||||
{splitN > 1 && segmentData && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{segmentData.map((seg, si) => {
|
||||
const isActive = selectedSegment === si;
|
||||
const segStart = startTs + si * segSize;
|
||||
const segEnd = startTs + (si + 1) * segSize;
|
||||
const fmt = s => {
|
||||
const t = Math.round(s);
|
||||
const clamped = Math.max(0, t);
|
||||
const m = Math.floor(clamped / 60);
|
||||
const sec = clamped % 60;
|
||||
const prefix = t < 0 ? '0:00*' : `${m}:${String(sec).padStart(2, '0')}`;
|
||||
return prefix;
|
||||
};
|
||||
return (
|
||||
<button key={si} type="button"
|
||||
onClick={() => setSelectedSegment(prev => prev === si ? null : si)}
|
||||
title={`${fmt(segStart)} – ${fmt(segEnd)}`}
|
||||
className={`inline-flex flex-col items-center px-2.5 py-1 rounded-lg text-xs font-medium border transition-all
|
||||
${isActive ? 'bg-gray-800 text-white border-gray-800' : 'bg-white text-gray-600 border-gray-300 hover:border-gray-400'}`}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{ORDINALS[si]}
|
||||
<span className={`text-[10px] px-1 py-px rounded-full font-semibold
|
||||
${isActive ? 'bg-white/20 text-white' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{seg.stats.total}
|
||||
</span>
|
||||
</span>
|
||||
<span className={`text-[9px] font-normal tabular-nums ${isActive ? 'text-gray-300' : 'text-gray-400'}`}>
|
||||
{fmt(segStart)}–{fmt(segEnd)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{selectedSegment !== null && (
|
||||
<button type="button" onClick={() => setSelectedSegment(null)}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 px-1">✕</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Selected segment stats ── */}
|
||||
{activeSegStats && (
|
||||
<div className="flex flex-wrap items-center gap-3 mb-2 px-3 py-2 bg-gray-50 rounded-lg border border-gray-200 text-xs">
|
||||
<span className="font-semibold text-gray-700">{ORDINALS[selectedSegment]} segment</span>
|
||||
<span className="text-gray-500">{activeSegStats.total} attempt{activeSegStats.total !== 1 ? 's' : ''}</span>
|
||||
{Object.entries(activeSegStats.byCat).sort(([, a], [, b]) => b - a).map(([cat, n]) => {
|
||||
const c = getColors(cat, customCategories);
|
||||
return (
|
||||
<span key={cat} className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full ${c.bg} ${c.text}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
|
||||
{cat}: {n}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Chip area ── */}
|
||||
<div className="flex flex-wrap gap-1 items-center">
|
||||
{splitN === 1
|
||||
? singleItems.map(item => <Chip key={item.id} item={item} customCategories={customCategories} dimmed={false} />)
|
||||
: segmentData && segmentData.map((seg, si) => (
|
||||
<React.Fragment key={si}>
|
||||
{si > 0 && (
|
||||
<div className="flex flex-col items-center mx-0.5 self-stretch" aria-hidden>
|
||||
<div className="w-px flex-1 bg-gray-300" style={{ minHeight: 20 }} />
|
||||
</div>
|
||||
)}
|
||||
{seg.items.length === 0
|
||||
? <span className="text-[10px] text-gray-300 italic px-1">empty</span>
|
||||
: seg.items.map(item => (
|
||||
<Chip key={`${si}-${item.id}`} item={item} customCategories={customCategories}
|
||||
dimmed={selectedSegment !== null && selectedSegment !== si} />
|
||||
))}
|
||||
</React.Fragment>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* ── Global totals ── */}
|
||||
<div className="flex flex-wrap items-center gap-3 mt-2 pt-2 border-t border-gray-100 text-xs text-gray-500">
|
||||
<span className="font-medium text-gray-700">Total: {globalStats.total} attempt{globalStats.total !== 1 ? 's' : ''}</span>
|
||||
{Object.entries(globalStats.byCat).sort(([, a], [, b]) => b - a).map(([cat, n]) => {
|
||||
const c = getColors(cat, customCategories);
|
||||
return (
|
||||
<span key={cat} className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full ${c.bg} ${c.text}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
|
||||
{cat}: {n}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({ item, customCategories, dimmed }) {
|
||||
const c = getColors(item.category, customCategories);
|
||||
return (
|
||||
<span
|
||||
title={item.count > 1 ? `${item.category} × ${item.count}` : item.category}
|
||||
className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono border transition-opacity
|
||||
${c.bg} ${c.text} ${c.border} ${dimmed ? 'opacity-25' : 'opacity-100'}`}>
|
||||
{item.count > 1 ? `${item.count}×` : ''}{item.category.slice(0, 3)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user