[aidan] feat/schedule-list: window long list via measured-height virtualizer

This commit is contained in:
abccodes
2026-06-19 19:48:40 -07:00
parent fbe22ae1d0
commit 6b85d006b5
2 changed files with 322 additions and 96 deletions
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Tooltip from '@mui/material/Tooltip';
@@ -12,6 +12,7 @@ import type { Workflow } from '@/shared/state/workflowsSlice';
import { runWorkflowNow, deleteWorkflow, updateWorkflow, openWorkflowCard } from '@/shared/state/workflowsSlice';
import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
import { WEEKDAY_FULL, WEEKDAY_LABEL_SHORT, addDays, sameDay, startOfMonthGrid, startOfWeek, formatTime, formatHourLabel, stepsSignature } from './scheduleUtils';
import { useWindowedList } from '@/shared/hooks/useWindowedList';
interface Props {
view: 'Week' | 'Month' | 'List';
@@ -25,14 +26,24 @@ interface Props {
// starting hour. The scroll container caps the visible window.
const HOURS_24 = Array.from({ length: 24 }, (_, i) => i);
// List view pages in this many occurrences at a time as you scroll.
const LIST_PAGE = 60;
// At/above this many list rows (day headers + event rows), window the list so
// only near-viewport rows stay mounted. Below it, render whole; spacers aren't
// worth the churn on a short list.
const LIST_WINDOW_MIN_ROWS = 60;
interface CalendarEvent {
workflow_id: string;
fire_at: string;
}
// One flattened list row. Windowing unmounts at this granularity, so a dense
// single day no longer mounts all ~96 of its rows just for being near the
// viewport: only the rows actually in view (plus buffer) stay in the DOM.
type ListRow =
| { kind: 'header'; id: string; date: Date; isToday: boolean }
| { kind: 'event'; id: string; ev: { workflow: Workflow; date: Date } }
| { kind: 'empty'; id: string };
export default function ScheduleCalendar({ view, density, onSelectWorkflow, refDate }: Props) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
@@ -109,18 +120,32 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
const rangeEndExclusive = useMemo(() => addDays(rangeStart, range), [rangeStart, range]);
const [calendarEvents, setCalendarEvents] = useState<CalendarEvent[]>([]);
const [calendarFetchKey, setCalendarFetchKey] = useState('');
// Key off only the fields that change which occurrences exist. Deliberately
// NOT updated_at: the scheduler bumps it every tick (recomputing next_run_at)
// and pushes a workflow:updated over the socket, which would churn this key
// and blank the calendar (the eventsByDay gate) until the next fetch lands.
const workflowScheduleKey = workflows
.map((w) => `${w.id}:${w.updated_at}:${w.schedule.enabled}:${w.schedule.timezone}:${w.schedule.repeat_unit}:${w.schedule.repeat_every}:${w.schedule.hour}:${w.schedule.minute}:${w.schedule.on_days.join(',')}:${w.schedule.ends_at || ''}:${w.schedule.max_runs ?? ''}:${w.schedule.runs_count}`)
.map((w) => `${w.id}:${w.schedule.enabled}:${w.schedule.timezone}:${w.schedule.repeat_unit}:${w.schedule.repeat_every}:${w.schedule.hour}:${w.schedule.minute}:${w.schedule.on_days.join(',')}:${w.schedule.ends_at || ''}:${w.schedule.max_runs ?? ''}:${w.schedule.runs_count}`)
.sort()
.join('|');
const fromIso = rangeStart.toISOString();
const toIso = rangeEndExclusive.toISOString();
const calendarRequestKey = `${view}:${fromIso}:${toIso}:${workflowScheduleKey}`;
// The visible window alone decides whether shown events are even plausible.
// Gating on this (not the full request key) means a schedule edit refetches
// without blanking the calendar first: we keep the current events until the
// fresh ones land. Only a view/date change, where old events are for the
// wrong window, clears them.
const calendarWindowKey = `${view}:${fromIso}:${toIso}`;
useEffect(() => {
// No AbortController: the global fetch interceptor (shared/config) dedupes
// GETs by URL onto ONE underlying request, so aborting on cleanup (which
// fires when this effect re-runs as workflows hydrate) rejects the shared
// request and the re-fired fetch with it, leaving the calendar empty on
// first load. The `cancelled` guard already stops stale state writes.
let cancelled = false;
const ctrl = new AbortController();
fetch(`${API_BASE}/workflows/calendar?from=${encodeURIComponent(fromIso)}&to=${encodeURIComponent(toIso)}`, { signal: ctrl.signal })
fetch(`${API_BASE}/workflows/calendar?from=${encodeURIComponent(fromIso)}&to=${encodeURIComponent(toIso)}`)
.then((res) => {
if (!res.ok) throw new Error(`calendar failed ${res.status}`);
return res.json();
@@ -128,22 +153,20 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
.then((data) => {
if (cancelled) return;
setCalendarEvents((data.events || []) as CalendarEvent[]);
setCalendarFetchKey(calendarRequestKey);
setCalendarFetchKey(calendarWindowKey);
})
.catch(() => {
if (cancelled) return;
setCalendarEvents([]);
setCalendarFetchKey(calendarRequestKey);
setCalendarFetchKey(calendarWindowKey);
});
return () => {
cancelled = true;
ctrl.abort();
};
}, [fromIso, toIso, calendarRequestKey]);
const eventsByDay = useMemo(() => {
const map = new Map<string, { workflow: Workflow; date: Date }[]>();
if (calendarFetchKey !== calendarRequestKey) {
if (calendarFetchKey !== calendarWindowKey) {
return { map, start: rangeStart, end: rangeEndExclusive, key: calendarFetchKey };
}
const workflowById = new Map(workflows.map((wf) => [wf.id, wf]));
@@ -161,25 +184,51 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
arr.sort((a, b) => a.date.getTime() - b.date.getTime());
}
return { map, start: rangeStart, end: rangeEndExclusive, key: calendarFetchKey };
}, [calendarEvents, calendarFetchKey, calendarRequestKey, workflows, rangeStart, rangeEndExclusive]);
}, [calendarEvents, calendarFetchKey, calendarWindowKey, workflows, rangeStart, rangeEndExclusive]);
// List view can fan out to ~1300 rows for a dense schedule (every 15 min over
// 14 days), so render a page at a time and grow as a bottom sentinel scrolls
// into view. root:null intersects against the viewport through whichever
// ancestor actually scrolls, so this works in both the popover and the hub.
const [listVisible, setListVisible] = useState(LIST_PAGE);
const listSentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => { setListVisible(LIST_PAGE); }, [dayKey, view, eventsByDay.key]);
useEffect(() => {
if (view !== 'List') return;
const el = listSentinelRef.current;
if (!el) return;
const obs = new IntersectionObserver((entries) => {
if (entries.some((e) => e.isIntersecting)) setListVisible((n) => n + LIST_PAGE);
}, { rootMargin: '300px' });
obs.observe(el);
return () => obs.disconnect();
}, [view, listVisible, eventsByDay.key, dayKey]);
// 14 days). Flatten days into rows and window at the row level so off-screen
// rows unmount instead of weighing the whole app down. Computed up here (not
// in the List branch) so the windowing hook runs before the Week/Month early
// returns.
const upcoming = useMemo(() => {
const out: { date: Date; events: { workflow: Workflow; date: Date }[]; isToday: boolean }[] = [];
for (let i = 0; i < 14; i += 1) {
const day = addDays(today, i);
const key = `${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`;
const arr = eventsByDay.map.get(key) || [];
const isToday = sameDay(day, today);
if (arr.length || isToday) out.push({ date: day, events: arr, isToday });
}
return out;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [eventsByDay, dayKey]);
const rows = useMemo<ListRow[]>(() => {
const out: ListRow[] = [];
for (const day of upcoming) {
const iso = day.date.toISOString();
out.push({ kind: 'header', id: `h:${iso}`, date: day.date, isToday: day.isToday });
if (day.events.length === 0) {
out.push({ kind: 'empty', id: `x:${iso}` });
} else {
for (const ev of day.events) {
out.push({ kind: 'event', id: `${iso}#${ev.workflow.id}#${ev.date.getTime()}`, ev });
}
}
}
return out;
}, [upcoming]);
const rowIds = useMemo(() => rows.map((r) => r.id), [rows]);
const estimateRowHeight = useCallback((index: number) => {
const r = rows[index];
if (!r) return 41;
return r.kind === 'header' ? 52 : r.kind === 'empty' ? 36 : 41;
}, [rows]);
const windowing = useWindowedList({
ids: rowIds,
estimateHeight: estimateRowHeight,
enabled: view === 'List' && rows.length >= LIST_WINDOW_MIN_ROWS,
});
const SLOT_H = compact ? 32 : 44;
const ROW_LABEL = compact ? '0.7rem' : '0.74rem';
@@ -346,81 +395,81 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
);
}
// Apple-Calendar-style list: big day number + weekday on the left, a
// vertical colored bar separating it from events on the right. Today
// renders even with no events (shows a "No events today" placeholder)
// so the list doesn't feel empty for new users.
const upcoming: { date: Date; events: { workflow: Workflow; date: Date }[]; isToday: boolean }[] = [];
for (let i = 0; i < 14; i += 1) {
const day = addDays(today, i);
const key = `${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`;
const arr = eventsByDay.map.get(key) || [];
const isToday = sameDay(day, today);
if (arr.length || isToday) upcoming.push({ date: day, events: arr, isToday });
}
// Apple-Calendar-style list: each day is a stacked group with the date as a
// header and its events listed underneath, so a busy day stays readable top
// to bottom instead of crammed beside a date column. Today renders even with
// no events (shows a "No events today" placeholder)
// so the list doesn't feel empty for new users. Off-screen day groups
// unmount (useWindowedList) and leave a measured-height spacer behind, so a
// dense schedule stays light no matter how far down you scroll.
const accent = c.accent.primary;
// Spend the page budget across day groups in order, truncating the group it
// runs out on; the leftover events appear when the sentinel grows the budget.
const totalEvents = upcoming.reduce((n, u) => n + u.events.length, 0);
let budget = listVisible;
const windowedDays: typeof upcoming = [];
for (const day of upcoming) {
windowedDays.push({ ...day, events: day.events.slice(0, Math.max(0, budget)) });
budget -= day.events.length;
if (budget <= 0) break;
}
const hasMore = listVisible < totalEvents;
const visibleRows = rows.slice(windowing.start, windowing.end);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.lg}px`, overflow: 'hidden', bgcolor: c.bg.surface }}>
{upcoming.length === 0 && (
<Box
ref={windowing.setScrollEl}
onScroll={windowing.onScroll}
sx={{ display: 'flex', flexDirection: 'column', maxHeight: '100%', overflow: 'auto', overflowAnchor: 'auto', bgcolor: c.bg.surface }}>
{rows.length === 0 && (
<Typography sx={{ fontSize: '0.85rem', color: c.text.muted, textAlign: 'center', py: 3 }}>No scheduled</Typography>
)}
{windowedDays.map(({ date, events, isToday }, rowIdx) => (
<Box
key={date.toISOString()}
sx={{
display: 'flex', alignItems: 'stretch',
borderTop: rowIdx === 0 ? 'none' : `1px dashed ${c.border.subtle}`,
minHeight: 64,
}}>
<Box sx={{ width: 96, flexShrink: 0, display: 'flex', alignItems: 'center', gap: 0.75, pl: 2, pr: 1.25 }}>
<Typography sx={{ fontSize: '1.55rem', fontWeight: 600, color: isToday ? accent : c.text.primary, lineHeight: 1, letterSpacing: '-0.01em' }}>
{date.getDate()}
</Typography>
<Box>
<Typography sx={{ fontSize: '0.78rem', color: isToday ? accent : c.text.secondary, fontWeight: 500, lineHeight: 1.2 }}>
{date.toLocaleString('en', { month: 'short' })}
{windowing.topSpacer > 0 && (
<Box aria-hidden sx={{ height: windowing.topSpacer, flexShrink: 0, overflowAnchor: 'none' }} />
)}
{visibleRows.map((row, i) => {
const rowIdx = windowing.start + i;
if (row.kind === 'header') {
return (
<Box
key={row.id}
data-wl-id={row.id}
sx={{
display: 'flex', alignItems: 'baseline', gap: 0.75,
px: 2, pt: rowIdx === 0 ? 1.5 : 2, pb: 0.5,
borderTop: rowIdx === 0 ? 'none' : `1px dashed ${c.border.subtle}`,
}}>
<Typography sx={{ fontSize: '1.15rem', fontWeight: 700, color: row.isToday ? accent : c.text.primary, lineHeight: 1, letterSpacing: '-0.01em' }}>
{row.date.getDate()}
</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1.2 }}>{WEEKDAY_FULL[date.getDay()]}</Typography>
<Typography sx={{ fontSize: '0.85rem', fontWeight: 600, color: row.isToday ? accent : c.text.secondary, lineHeight: 1 }}>
{WEEKDAY_FULL[row.date.getDay()]}
</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1 }}>
{row.date.toLocaleString('en', { month: 'short' })}
</Typography>
</Box>
);
}
if (row.kind === 'empty') {
return (
<Box key={row.id} data-wl-id={row.id} sx={{ px: 2, pb: 1 }}>
<Typography sx={{ fontSize: '0.85rem', color: c.text.ghost }}>No events today</Typography>
</Box>
);
}
const e = row.ev;
return (
<Box
key={row.id}
data-wl-id={row.id}
onClick={() => onSelectWorkflow?.(e.workflow.id)}
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }}
sx={{
display: 'flex', alignItems: 'center', gap: 1.25,
px: 2, py: 0.4,
color: c.text.secondary, cursor: 'pointer',
'&:hover .ev-title': { color: accent },
}}>
<Box sx={{ width: 3, alignSelf: 'stretch', minHeight: 22, bgcolor: accent, borderRadius: c.radius.sm, flexShrink: 0 }} />
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography className="ev-title" sx={{ fontSize: '0.9rem', fontWeight: 500, color: c.text.primary, lineHeight: 1.3 }}>{e.workflow.title}</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1.3 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</Typography>
</Box>
</Box>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', py: 1, pr: 2 }}>
{events.length === 0 && (
<Typography sx={{ fontSize: '0.85rem', color: c.text.ghost }}>No events today</Typography>
)}
{events.map((e, idx) => (
<Tooltip key={`${e.workflow.id}-${idx}`} title={<EventTooltipBody event={e} />} placement="right" arrow>
<Box
onClick={() => onSelectWorkflow?.(e.workflow.id)}
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }}
sx={{
display: 'flex', alignItems: 'center', gap: 1.25,
py: 0.4,
fontSize: '0.88rem', color: c.text.secondary, cursor: 'pointer',
'&:hover .ev-title': { color: accent },
}}>
<Box sx={{ width: 3, alignSelf: 'stretch', minHeight: 22, bgcolor: accent, borderRadius: c.radius.sm, flexShrink: 0 }} />
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography className="ev-title" sx={{ fontSize: '0.9rem', fontWeight: 500, color: c.text.primary, lineHeight: 1.3 }}>{e.workflow.title}</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1.3 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</Typography>
</Box>
</Box>
</Tooltip>
))}
</Box>
</Box>
))}
{hasMore && <Box ref={listSentinelRef} sx={{ height: 1 }} />}
);
})}
{windowing.bottomSpacer > 0 && (
<Box aria-hidden sx={{ height: windowing.bottomSpacer, flexShrink: 0, overflowAnchor: 'none' }} />
)}
{ctxMenuEl}
</Box>
);
@@ -0,0 +1,177 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
// Generic list windowing, lifted from AgentChat's transcript virtualizer so a
// long schedule list mounts only the rows near the viewport (off-screen rows
// unmount, replaced by measured-height spacers). Rows can be any height; the
// hook measures them once on screen and estimates the rest. Top-anchored: the
// list reads from the top, no bottom-following like the chat does.
// Keep this many screens of real content mounted on EACH side of the viewport.
const BUFFER_SCREENS_PER_SIDE = 3;
// Floor on mounted count so one very tall row can't strand an empty window.
const MIN_BUFFER_ITEMS = 2;
// Pure solver: given scroll position and a per-index height accessor (measured
// where known, estimated otherwise), return the [start, end) slice to mount.
// Buffer is in PIXELS (N screens per side), so a few tall rows can't blow the
// mounted set up to the whole list.
export function computeDesiredWindow(
scrollTop: number,
clientHeight: number,
total: number,
heightOf: (index: number) => number,
bufferPx: number,
): { start: number; end: number } {
if (total <= 0) return { start: 0, end: 0 };
const keepTop = scrollTop - bufferPx;
const keepBottom = scrollTop + clientHeight + bufferPx;
let offset = 0;
let start = -1;
let end = total;
for (let i = 0; i < total; i++) {
const h = heightOf(i);
const itemTop = offset;
const itemBottom = offset + h;
if (start === -1 && itemBottom > keepTop) start = i;
if (itemTop < keepBottom) {
end = i + 1;
} else {
break;
}
offset += h;
}
if (start === -1) start = Math.max(0, total - 1);
end = Math.min(total, Math.max(end, start + 1));
if (end - start < MIN_BUFFER_ITEMS) {
start = Math.max(0, Math.min(start, end - MIN_BUFFER_ITEMS));
}
return { start: Math.max(0, start), end };
}
interface UseWindowedListArgs {
// Stable id per row, in render order. Heights are cached by id so a measured
// row keeps its height across re-renders even as the window slides.
ids: string[];
estimateHeight: (index: number) => number;
// Off below this gates windowing entirely: render all, no spacers. Short
// lists don't benefit and the spacer recompute just fights the scrollbar.
enabled: boolean;
}
interface UseWindowedListResult {
setScrollEl: (el: HTMLDivElement | null) => void;
onScroll: () => void;
start: number;
end: number;
topSpacer: number;
bottomSpacer: number;
}
export function useWindowedList({ ids, estimateHeight, enabled }: UseWindowedListArgs): UseWindowedListResult {
const total = ids.length;
const [scrollEl, setScrollEl] = useState<HTMLDivElement | null>(null);
const heightsRef = useRef<Map<string, number>>(new Map());
const [heightVersion, setHeightVersion] = useState(0);
const [start, setStart] = useState(0);
const [end, setEnd] = useState(total);
const startRef = useRef(0);
const endRef = useRef(total);
const idsRef = useRef(ids);
idsRef.current = ids;
const estimateRef = useRef(estimateHeight);
estimateRef.current = estimateHeight;
const heightOf = useCallback((index: number): number => {
const id = idsRef.current[index];
if (id == null) return 0;
const measured = heightsRef.current.get(id);
if (measured != null) return measured;
return estimateRef.current(index);
}, []);
const applyWindow = useCallback(() => {
const el = scrollEl;
if (!el || !enabled) return;
const count = idsRef.current.length;
const clientHeight = Math.max(1, el.clientHeight);
const tightPx = BUFFER_SCREENS_PER_SIDE * clientHeight;
const loosePx = tightPx + clientHeight;
const tight = computeDesiredWindow(el.scrollTop, clientHeight, count, heightOf, tightPx);
const loose = computeDesiredWindow(el.scrollTop, clientHeight, count, heightOf, loosePx);
const curStart = startRef.current;
const curEnd = endRef.current;
// Hysteresis: must-mount the tight band, but keep already-mounted edges
// until they drift past the looser band, so rows on the boundary don't
// flip-flop mount/unmount on every scroll tick.
let next = Math.max(loose.start, Math.min(curStart, tight.start));
let nextEnd = Math.min(loose.end, Math.max(curEnd, tight.end));
next = Math.max(0, Math.min(next, Math.max(0, nextEnd - 1)));
if (next === curStart && nextEnd === curEnd) return;
startRef.current = next;
endRef.current = nextEnd;
setStart(next);
setEnd(nextEnd);
}, [scrollEl, enabled, heightOf]);
const rafRef = useRef<number | null>(null);
const onScroll = useCallback(() => {
if (rafRef.current != null) return;
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
applyWindow();
});
}, [applyWindow]);
useEffect(() => {
if (!scrollEl) return;
applyWindow();
const obs = new ResizeObserver(() => applyWindow());
obs.observe(scrollEl);
return () => {
obs.disconnect();
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
}, [scrollEl, enabled, total, heightVersion, applyWindow]);
// Measure mounted rows after paint; a real height replaces its estimate and
// nudges the window + spacers to the truth on the next frame.
useLayoutEffect(() => {
if (!scrollEl) return;
let changed = false;
scrollEl.querySelectorAll<HTMLElement>('[data-wl-id]').forEach((node) => {
const id = node.dataset.wlId;
if (!id) return;
const h = node.offsetHeight;
if (h <= 0) return;
const prev = heightsRef.current.get(id);
if (prev === undefined || Math.abs(prev - h) > 1) {
heightsRef.current.set(id, h);
changed = true;
}
});
if (changed) setHeightVersion((v) => v + 1);
});
const safeStart = enabled ? Math.min(Math.max(0, start), Math.max(0, total - 1)) : 0;
const safeEnd = enabled ? Math.min(Math.max(safeStart + 1, end), total) : total;
const { topSpacer, bottomSpacer } = useMemo(() => {
if (!enabled) return { topSpacer: 0, bottomSpacer: 0 };
let top = 0;
for (let i = 0; i < safeStart; i++) top += heightOf(i);
let bottom = 0;
for (let i = safeEnd; i < total; i++) bottom += heightOf(i);
return { topSpacer: top, bottomSpacer: bottom };
// heightVersion: spacers depend on the measured-height map (a ref the dep
// checker can't see), recompute when a measurement lands.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, safeStart, safeEnd, total, heightVersion, heightOf]);
return { setScrollEl, onScroll, start: safeStart, end: safeEnd, topSpacer, bottomSpacer };
}