Performance
What is drawn where
The page renders the chart once as JSON: the window, the groups and the bars overlapping it. From then on the browser owns the chart. A drag moves a bar with no round trip; the server is asked afterwards with a renderless Livewire call that answers with one bar, never with HTML. Switching the scale, folding a group or scrolling touches the server not at all; changing the window or a filter loads the bars of the new window only.
When something else on the page re-renders the component (an action's modal closing, a header action, $refresh), the chart element is wire:ignore and that render carries no bars at all: the component remembers that the chart's state has been rendered ($ganttRendered, set by the render that included the chart), so the bars are neither queried nor serialised again, and the browser reloads them renderless when an action asks it to (packstub-gantt-refresh). A chart that appears only on a later render (behind a tab, say) gets its bars on that render; should a chart ever start without a window, it asks the server for its state itself. A later render leaves out the filters' options as well (often queries): a chart new on such a render asks for them along with its state.
All the maths lives in one pure function, layout(bars, window, scale) in gantt.js: bars in, pixel columns and rows out.
Rows
Above 200 rows the chart windows them: only the rows in view (plus a margin) are in the DOM, the rest is empty space with the right height. Scrolling swaps rows in and out. A thousand bars stay smooth; the JSON for them is a few hundred kilobytes. Columns get the same treatment above 400 (hours over weeks): only those in view, and a screen either side, are drawn.
Queries
- One query for the bars of the window (plus a count when it holds more than
max_bars), one aggregate for the default window (min start, max end, read from a subquery of the two columns, so awithCount()oraddSelect()in your query is fine on MySQL and PostgreSQL), one for a listedgroups()closure. Filters are extrawheres on the bars query. - Eager-load what
label(),color()andbar()touch:->query(fn () => Task::query()->with(['project', 'assignee'])). AgroupBy()relation is eager-loaded for you when its label reads it. - Each bar asks each bar action whether the user may run it on that record (Filament's visibility and authorization, a policy check per action and bar): keep
hidden()andvisible()closures to attribute checks. - Index the start and end columns: the window filter is a plain range on each,
start < (last day + 1)andend >= first day(neverDATE(column)), so a B-tree index on either column is used. AFilter::dateRange()narrows its column the same way. - Rules (
movable(),resizable(),authorizeMoves()) are evaluated per bar while reading, so keep them to attribute checks; put queries incanMove(), which runs once per move.readOnly()is evaluated once per request.
Windows
The default window is padded around the bars; when that is longer than max_window_days (730), the chart opens around today instead, and the date-range picker moves it. At most max_bars (2,000) bars are loaded per window. Set startDate()/endDate() to open on the span that matters (this quarter, the next six weeks).
Polling
poll() reloads the window's bars every so often. It waits while a bar is being dragged, moved from the keyboard or saved and while the tab is hidden, and a stale answer is dropped, so it never fights the user.