From 6b85d006b5d7e6e059ae550e1cff1eacf20704fd Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 19 Jun 2026 19:48:40 -0700 Subject: [PATCH] [aidan] feat/schedule-list: window long list via measured-height virtualizer --- .../app/pages/Workflows/ScheduleCalendar.tsx | 241 +++++++++++------- frontend/src/shared/hooks/useWindowedList.ts | 177 +++++++++++++ 2 files changed, 322 insertions(+), 96 deletions(-) create mode 100644 frontend/src/shared/hooks/useWindowedList.ts diff --git a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx index 29239a88..2acbfe7c 100644 --- a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx +++ b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx @@ -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([]); 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(); - 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(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(() => { + 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 ( - - {upcoming.length === 0 && ( + + {rows.length === 0 && ( No scheduled )} - {windowedDays.map(({ date, events, isToday }, rowIdx) => ( - - - - {date.getDate()} - - - - {date.toLocaleString('en', { month: 'short' })} + {windowing.topSpacer > 0 && ( + + )} + {visibleRows.map((row, i) => { + const rowIdx = windowing.start + i; + if (row.kind === 'header') { + return ( + + + {row.date.getDate()} - {WEEKDAY_FULL[date.getDay()]} + + {WEEKDAY_FULL[row.date.getDay()]} + + + {row.date.toLocaleString('en', { month: 'short' })} + + + ); + } + if (row.kind === 'empty') { + return ( + + No events today + + ); + } + const e = row.ev; + return ( + 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 }, + }}> + + + {e.workflow.title} + {formatTime(e.date.getHours(), e.date.getMinutes())} - - {events.length === 0 && ( - No events today - )} - {events.map((e, idx) => ( - } placement="right" arrow> - 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 }, - }}> - - - {e.workflow.title} - {formatTime(e.date.getHours(), e.date.getMinutes())} - - - - ))} - - - ))} - {hasMore && } + ); + })} + {windowing.bottomSpacer > 0 && ( + + )} {ctxMenuEl} ); diff --git a/frontend/src/shared/hooks/useWindowedList.ts b/frontend/src/shared/hooks/useWindowedList.ts new file mode 100644 index 00000000..66e74721 --- /dev/null +++ b/frontend/src/shared/hooks/useWindowedList.ts @@ -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(null); + + const heightsRef = useRef>(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(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('[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 }; +}