Files
experiments-database/frontend/src/components/ExperimentCalendar.jsx
T
Experiments DB Dev d2c8d69718 perf: server-side caching + remove bulk status fetch from experiment page
- 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>
2026-04-23 14:03:59 -04:00

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">&#8249;</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">&#8250;</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>
);
}