Skip to content

[schedule] Reduce render, drag and network overhead#2142

Open
frankrousseau wants to merge 24 commits into
cgwire:mainfrom
frankrousseau:perf/schedule
Open

[schedule] Reduce render, drag and network overhead#2142
frankrousseau wants to merge 24 commits into
cgwire:mainfrom
frankrousseau:perf/schedule

Conversation

@frankrousseau

Copy link
Copy Markdown
Contributor

Problem

  • Document-level mousemove listeners forced a layout on every mouse move anywhere on the page, and drag processing ran once per event instead of once per frame
  • Each task-type expand fetched the tasks and entities twice, refetched the days off every time, and filtered entities with O(entities x tasks) scans
  • Applying assignments issued one sequential request per entity for versioned tasks and for task updates
  • Render-time helpers reallocated day-off ranges, timesheet filters and selection scans for every bar on every render
  • setItemPositions allocated a cell per day of the range on every drag tick and weeksAvailable reparsed a moment per day just to keep the Mondays

Solution

  • Attach move listeners only during drags, cache the wrapper rect and throttle drag work with requestAnimationFrame
  • Reuse the expand loads for the side panel, replace the linear scans with Sets/Maps and cache the days off per date range
  • Load versioned tasks once per assignment run and flush task updates in small parallel batches
  • Memoize the day-off ranges and timesheet groups, make isSelected a Set lookup
  • Pack timebar lines with per-line interval lists and iterate the week headers Monday to Monday

Note: stacked on #2141, only the 14 perf commits are new here.

frankrousseau and others added 24 commits July 16, 2026 15:49
A forced daily quota of 0 (or a field emptied by keyboard, which yields
an empty string that ?? does not catch) made taskEstimation infinite:
addBusinessDays then looped forever and froze the tab on Apply.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The editable flag of root task-type bars was only computed in loadData,
with the default mode and version. Switching to the real mode or to a
locked version (or arriving with ?mode=real in the URL) kept the bars
draggable, and drags were persisted on a schedule that should be locked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A multi-assignee task renders one bar per assignee, but reassigning a
bar always removed assignees[0], which could unassign the wrong person
server-side. The drag now remembers the person row it started on.

The drag handler also mutated the assignees list before emitting
item-assign, whose handler pushes the new assignee again: the id ended
up duplicated. The page handlers now own those updates, and the
unassign handler skips the API call for the local 'unassigned' row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task dates are not clamped to the production range, so a task starting
before or ending after it had no entry in the displayed-days index:
dragging its bar silently did nothing (NaN length) and dragging its
left handle threw on every mousemove tick (newStartDate undefined).

The index lookups now resolve out-of-range dates to the nearest
boundary, changeStartDate guards against a missing day and the
changeEndDate clamp compares against the arrays instead of a .length
that never existed on the index objects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
getDisplayedWeeksIndex called startOf('isoweek') on the moment it
received, mutating it in place. In week zoom, resizing a bar snapped
the item's real end date back to its week's Monday without validation,
and the change was then persisted. The week-mode drag also assigned
the weeksAvailable moments to items without cloning, aliasing the
header entries with the item dates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Selecting or dragging an entity-type row replaced its children array
with the filtered (unassigned only) list. The assigned entities were
gone for good: toggling "show assigned" never brought them back. The
dragged entry is now a copy carrying the filtered children.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
loadData had no catch: a failed load left an unhandled rejection, never
raised errors.schedule and aborted the query-param restore. The version
edit and delete flows closed their modal before awaiting the API call,
and their error flags were never set, so failures were invisible. The
modals now stay open until the call succeeds and show the error state.
loading.delete, bound in the template, was also missing from data().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The zoom watcher called onTimelineScroll(null, ...), which returns
immediately on a null event and ignores a second argument: the intended
scroll reset never happened. Reset the wrapper and header scroll
positions directly instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The task-type fills passed 6-digit hex values where ExcelJS expects
8-digit ARGB (as the header already does with FFDDDDDD): Excel read the
first byte as alpha and rendered wrong colors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
expandAll was toggled without being declared in data(), the task
end-date fallback was duplicated verbatim, getNbSubChildren was unused
(and read a Map with Object.values, always yielding 0) and
weeksAvailable stamped properties on Date objects that nothing reads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The document-level mousemove/touchmove listeners lived for the whole
component lifetime and forced a layout (getBoundingClientRect) plus a
style write on every mouse move anywhere on the page. They are now
attached on interaction start and removed on release. The hover
position bar is driven by a local listener on the timeline wrapper,
throttled with requestAnimationFrame and reading a cached rect that is
invalidated on resize and zoom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Expanding a task type loaded the tasks and the entities, then called
selectTaskTypeElement which fetched the exact same tasks and entities
again: two full asset (or shot/sequence/episode/edit) fetches and two
task fetches per expand. The expand now hands its unversioned task list
to the side panel, which skips the redundant loads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each entity-type branch filtered every asset/shot/sequence/episode/edit
with tasks.some(entity_id === id), an O(entities x tasks) scan on every
side-panel refresh. A Set built once replaces the linear lookups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
isSelected scanned the selection array for every bar on every render.
A computed Set of selected ids turns it into an O(1) lookup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Gaming mice fire mousemove far above the display refresh rate, and each
event ran the full drag pipeline (date computations, item positions).
The handler now processes only the latest event per animation frame.
The pan-scroll switches from per-event movementX/Y to the cumulative
client delta so skipped events lose no distance, and a pending frame is
cancelled on release so it cannot move the item after the drop is
saved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
saveAssignments fetched the schedule-version tasks inside the entity
loop, one HTTP request per entity, although they all belong to the
selected task type. They are now loaded once and indexed by task id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
saveAssignments awaited one updateTask round-trip per entity in
sequence, so applying assignments on a large selection took dozens of
seconds. The updates are collected during the distribution loop and
flushed in small parallel batches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The distribution loop searched the full task list for every entity, an
O(entities x tasks) scan per assignment run. A Map built once replaces
the linear find.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every task-type expand refetched the production days off although they
only depend on the production and the schedule range. The result is
kept until the production or the range changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The line packing allocated one array cell per day of the range for
every line (hundreds of cells per line on long schedules) and recursed
per collision, and it runs on every drag tick. Per-line interval lists
give the same first-free-line assignment without the allocations or
the recursion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The template expanded every root's and person's daysOff array with
getDayOffRange on each render, reallocating the ranges over and over.
A WeakMap keyed on the source array caches the expansion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pages with timesheets filtered the root's full timesheet list for every
child row on every render, an O(rows x timesheets) scan. The timesheets
are grouped by task id once per source array and cached in a WeakMap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
daysAvailable ran an includes() over the expanded day-off list for
every displayed day, O(days x days off) on each recompute.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The week list walked the whole range day by day, formatting and
reparsing a moment for each day just to keep the Mondays. Jumping seven
days at a time from the first Monday gives the same list with a seventh
of the iterations and no reparse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant