d2c8d69718
- Add backend/src/lib/cache.js — in-process TTL Map cache with prefix-based invalidation; no external dependencies - Cache /experiments/:id/calendar?field=X per (experimentId, field). Invalidated immediately on any DailyStatus write. - Cache /experiments/:id/daily-statuses per (experimentId, date, analysisOnly). Invalidated immediately on any DailyStatus write (create/update/delete and analysis-summary saves all clear the relevant prefix). - Add ?analysisOnly=true to /daily-statuses: filters rows where analysis_summary IS NOT NULL. Charts are the only consumer and already discard rows without analysis data; pre-filtering server-side avoids sending the full status payload (71 rows → 1 for this experiment). - ExperimentCalendar: remove allStatuses prop, fetch calendar data directly via API. The component now issues one small GET per field selection change instead of the parent loading thousands of rows upfront. - ExperimentDetail: pass analysisOnly:true to getDailyStatuses; drop allStatuses prop from ExperimentCalendar. Cold experiment page load no longer fetches every daily status record. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
100 lines
4.5 KiB
React
100 lines
4.5 KiB
React
import React, { useState, useEffect, useMemo } from 'react';
|
|
import {
|
|
format, startOfMonth, endOfMonth, eachDayOfInterval,
|
|
getDay, addMonths, subMonths,
|
|
} from 'date-fns';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { experimentsApi } from '../api/client';
|
|
|
|
export default function ExperimentCalendar({ experimentId, template }) {
|
|
const navigate = useNavigate();
|
|
const [currentMonth, setCurrentMonth] = useState(() => new Date());
|
|
const [selectedField, setSelectedField] = useState(null);
|
|
const [calendarDays, setCalendarDays] = useState({});
|
|
|
|
const fieldOptions = useMemo(
|
|
() => template.filter((f) => f.active).map((f) => ({ value: f.builtin ? f.key : f.fieldId, label: f.label })),
|
|
[template],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (fieldOptions.length === 0 || selectedField) return;
|
|
const weightField = fieldOptions.find((f) => f.label.toLowerCase().includes('weight'));
|
|
setSelectedField(weightField ? weightField.value : fieldOptions[0]?.value ?? null);
|
|
}, [fieldOptions]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
useEffect(() => {
|
|
if (!selectedField) return;
|
|
let cancelled = false;
|
|
experimentsApi.getCalendar(experimentId, selectedField)
|
|
.then(({ days }) => { if (!cancelled) setCalendarDays(days); })
|
|
.catch(() => {});
|
|
return () => { cancelled = true; };
|
|
}, [experimentId, selectedField]);
|
|
|
|
const monthStart = startOfMonth(currentMonth);
|
|
const monthEnd = endOfMonth(currentMonth);
|
|
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
|
const leadingPad = getDay(monthStart);
|
|
|
|
return (
|
|
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm mt-6">
|
|
<h3 className="text-base font-semibold text-gray-900 mb-4">Daily Record Calendar</h3>
|
|
|
|
<div className="flex flex-wrap items-center gap-3 mb-5">
|
|
<span className="text-sm text-gray-600 font-medium">Count by field:</span>
|
|
<select
|
|
data-testid="field-select"
|
|
className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
|
|
value={selectedField ?? ''}
|
|
onChange={(e) => setSelectedField(e.target.value)}
|
|
disabled={fieldOptions.length === 0}
|
|
>
|
|
{fieldOptions.map((f) => <option key={f.value} value={f.value}>{f.label}</option>)}
|
|
{fieldOptions.length === 0 && <option value="">No fields configured</option>}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
<button aria-label="Previous month" onClick={() => setCurrentMonth((m) => subMonths(m, 1))}
|
|
className="p-2 rounded-lg hover:bg-gray-100 text-gray-500 text-lg leading-none">‹</button>
|
|
<span className="font-semibold text-gray-900 text-base">{format(currentMonth, 'MMMM yyyy')}</span>
|
|
<button aria-label="Next month" onClick={() => setCurrentMonth((m) => addMonths(m, 1))}
|
|
className="p-2 rounded-lg hover:bg-gray-100 text-gray-500 text-lg leading-none">›</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-7 mb-1">
|
|
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => (
|
|
<div key={d} className="text-center text-xs font-medium text-gray-400 py-1 select-none">{d}</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-7 gap-1">
|
|
{Array.from({ length: leadingPad }).map((_, i) => <div key={`pad-${i}`} />)}
|
|
{days.map((day) => {
|
|
const dateStr = format(day, 'yyyy-MM-dd');
|
|
const count = calendarDays[dateStr] ?? 0;
|
|
const hasData = count > 0;
|
|
return (
|
|
<button
|
|
key={dateStr}
|
|
data-testid={`day-${dateStr}`}
|
|
disabled={!hasData}
|
|
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',
|
|
].join(' ')}
|
|
>
|
|
<span className={hasData ? 'font-semibold text-blue-900 text-xs' : 'text-xs'}>{day.getDate()}</span>
|
|
{hasData && (
|
|
<span data-testid={`count-${dateStr}`} className="text-[10px] leading-none font-bold text-blue-600 mt-0.5">{count}</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|