perf(web): window the schedule task fetch — streaming pages into the cache renders wrong WBS codes and a wrong critical path

Follow-up to #2277 (closed), which capped the schedule's page-fetch burst at 4 concurrent requests but still resolves the whole page set before mapping. The remaining half of #2277 (closed)'s original acceptance — "time-to-first-interactive on the schedule is bounded by the visible window, not total task count" — is not met and is tracked here.

Why streaming pages into the cache is not a small change

The obvious implementation (setQueryData per page as it lands, so the Gantt paints after page 1) does not render an incomplete schedule. It renders a wrong one, in two independent places:

  1. computeWbsCodes is positional. packages/web/src/utils/computeWbsCodes.ts numbers siblings by their index in the array it is handed (`${i + 1}`), and assignCodes recurses only through parents present in that array. So root tasks A, C, E arriving on page 1 display as WBS 1, 2, 3 and silently renumber to 1, 3, 5 when page 2 brings B and D. A task whose parent has not loaded is unreachable from the root walk entirely and falls back to its stale stored wbs.

  2. Link criticality is derived from the loaded set. useScheduleTasks builds criticalTaskIds from whichever tasks are currently in the cache, so a dependency arrow between two genuinely critical tasks renders non-critical until both endpoints have arrived.

Both are visible to the user and both resolve themselves a second later, which is worse than a stable wait: the schedule shows a defensible-looking WBS code and critical path that are simply incorrect while it fills.

What a fix has to do first

  • Make WBS code assignment stable under a partial set — either derive codes from the server's wbs_path rather than array position, or defer code assignment until the set is known complete.
  • Make link criticality resolve against tasks not yet loaded (server-side is_critical on the dependency, or suppress the arrow's critical styling until both endpoints exist) rather than defaulting to non-critical.
  • Only then window the fetch to the visible WBS range and expand on scroll.

Not in scope here

The WebSocket half of #2277 (closed)'s acceptance — "without a full refetch on every WS event" — is owned by #2341 (splice task_updated instead of refetching) and #1537 (route task_note_* through the scheduleInvalidate debounce). ADR-0091 already delta-splices task_dates_updated.

Acceptance

  • Opening a ~5k-task schedule paints an interactive first window without waiting for the full set
  • No WBS code and no critical-path styling changes value between the first paint and the fully-loaded state

Constraints (1) and (2) were found by reading the code while fixing #2277 (closed), and were verified against main; they are not hypothetical.