Skip to content

Commit d0dd979

Browse files
vincentmakesclaude
andauthored
Keep the Gantt tree panel and timeline in lock-step while scrolling (#81)
The two panes are separate vertical scroll containers mirrored by useScrollSync. The follower lagged a frame and stuttered during fast scrolls: its own scroll event arrived after the rAF guard had reset and copied its older position back onto the pane being scrolled. The hook now remembers each value it writes and ignores the follower's matching scroll event, and a non-passive wheel listener moves both panes in the same frame (Ctrl+wheel zoom, Shift+wheel and horizontal trackpad gestures are left to the browser). The last rows also misaligned by up to 8 px: the left panes carried a fixed 8 px bottom padding "to match the horizontal scrollbar", which is 0 px tall with overlay scrollbars. The hook now measures both panes' natural scroll range and pads the shorter one, re-measured one frame after a ResizeObserver notification; the fixed paddings are gone from the five stylesheets. trail check flags one keyword match (vi.stubGlobal against the window.location learning); nothing ruled out is reintroduced. Release 1.10.1. Claude-Session: https://claude.ai/code/session_01FPFQEucReHG1Wo9R6PpEpj Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2f77d1e commit d0dd979

12 files changed

Lines changed: 437 additions & 58 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
title: "Gantt two-pane scroll sync: no echo, wheel-driven, measured equal scroll range"
3+
author: Claude
4+
date: 2026-10-17
5+
branch: fix/gantt-scroll-sync
6+
cost: { minutes: 12, tokens: 78066 }
7+
touches:
8+
- CHANGELOG.md
9+
- CLAUDE.md
10+
- UI_GUIDELINES.md
11+
- VERSION
12+
- frontend/src/components/gantt/ProjectPanel/ProjectPanel.module.css
13+
- frontend/src/components/views/ArchivedView/ArchivedView.module.css
14+
- frontend/src/components/views/CrossSiteView/CrossSiteView.module.css
15+
- frontend/src/components/views/EquipmentView/EquipmentView.module.css
16+
- frontend/src/components/views/StaffView/StaffView.module.css
17+
- frontend/src/hooks/__tests__/useScrollSync.test.ts
18+
---
19+
20+
## Decided
21+
- `useScrollSync` keeps the tree panel and the timeline body together with three behaviours: every programmatic `scrollTop` write is remembered and the follower's matching `scroll` event (within 1 px) is consumed, so the follower never snaps the source back; a non-passive `wheel` listener on both panes moves both in the same frame (Ctrl, Shift, any `deltaX` and zero `deltaY` are left to the browser so zoom and sideways trackpad panning keep working); and the hook measures both panes' natural `scrollHeight - clientHeight` and pads the shorter one so both reach the same scroll end.
22+
- The re-measure after a `ResizeObserver` notification runs one `requestAnimationFrame` later (merged), otherwise changing padding inside the callback raises "ResizeObserver loop completed with undelivered notifications". jsdom has no `ResizeObserver`: the hook falls back to a `window` resize listener, tests stub the observer.
23+
24+
## Tried and ruled out
25+
- A fixed `padding-bottom: 8px` on the left pane "to match the horizontal scrollbar": the scrollbar takes 8 px of layout only with classic scrollbars; with overlay scrollbars (macOS) and `scrollbar-gutter: stable` it takes 0 and the left pane scrolls 8 px further, misaligning the last rows.
26+
- A rAF-reset `isSyncing` flag against echo: the follower's `scroll` event arrives a frame later, after the reset, and copies its older position back, which is the stutter during fast scrolls.
27+
- One shared vertical scroller with a sticky left panel would make alignment exact by construction, but the drag, resize and dependency layers locate rows by DOM position in the two-pane structure; too large for a fix.
28+
29+
## Watch out
30+
- Scroll events coalesce per frame; the echo guard assumes the follower's next `scroll` reports the last written value. Two writes to one pane in one frame are fine; a browser firing two separate events would cause one harmless snap.
31+
- Mouse-wheel notches jump instantly on the wheel path (no smooth scrolling); accepted.

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to Milestone Planner are documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/).
77

8+
## [1.10.1] - 2026-10-17
9+
10+
### Fixed
11+
- The Gantt tree panel and the timeline now scroll together without the panel trailing by a frame or stuttering during fast scrolls, in the Gantt and in the Staff, Equipment, Cross-site and Archived views.
12+
- Rows stay aligned at the very bottom of the list: the two panes always reach the same scroll end, whatever the horizontal scrollbar's height.
13+
814
## [1.10.0] - 2026-10-17
915

1016
### Changed

‎CLAUDE.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,7 +1047,7 @@ All backend↔frontend field mapping is centralized. The frontend model is mostl
10471047
- `ProjectPanel/`: header actions, right-aligned across the name column + custom columns (new/import/manage columns/visibility/tag filter — `TagFilterDropdown` from `components/common`, also mounted in the Kanban toolbar — then `GroupByDropdown` and `ColorByDropdown`, both thin wrappers over the shared `TagGroupChoiceDropdown` shell; the header dropdowns hang from the positioned `.headerContent`, right-aligned and width-capped) + `SectionRow` (Group-by section header) + rows `ProjectRow → PhaseRow → SubphaseRow → AssignmentRow` + custom-column cells; wrapped in `ReorderProvider`.
10481048
- `Timeline/`: `TimelineHeader` + `TimelineBody` — grid cells (weekend/holiday/company-event backgrounds; week separators when weekends are hidden), `TodayLine`, per-project bars (`ProjectBar`/`PhaseBar`), `DependencyLayer` (arrows), phantom overlays, `DragIndicator`, `ResourceDropOverlay`. Handles scroll persist/restore (200 ms debounce), zoom re-centering on the visible center date, view-mode changes preserving the left-edge date, Ctrl+wheel zoom, and scroll-to-today/date triggers.
10491049
- **Group by tag group**: `GanttContainer` turns `useGanttGrouping()` + the tag-filtered projects into one flat `GanttRow[]` (`components/gantt/utils/projectSections.ts`: `buildProjectSections` → `buildGanttRows`; first tag in group order wins, empty sections omitted, "No <group>" last) and hands the **same array** to `ProjectPanel` (`SectionRow`) and `TimelineBody` (`div.groupBand`, a direct child of `.rows`); both panes fall back to a memoised flat list when `rows` is omitted. Section rows are `--section-row-height` (36 px) + 1 px border, content-box, = `SECTION_ROW_TOTAL_HEIGHT` (37) in `calculateRowPositions(rows, …)`, keyed `section-<key>`; collapsed sections' projects get no position (no arrows, no drop target). Section class names must not contain `row` and carry no `data-project-id`/`data-phase-id` (the drag/resize hooks and `DependencyLayer` find rows by `[class*="row"]` / data attributes). Project drag-reorder is off while grouped (`onProjectReorder` undefined).
1050-
- Scroll sync: `TimelineScrollContext` (horizontal, direct DOM with feedback-loop guard) across Gantt/Staff/Equipment timelines; `useScrollSync` (vertical) between tree panel and timeline body.
1050+
- Scroll sync: `TimelineScrollContext` (horizontal, direct DOM with feedback-loop guard) across Gantt/Staff/Equipment timelines; `useScrollSync` (vertical) between tree panel and timeline body — wheel-driven in the same frame, echo-free, and padding the shorter pane so both reach the same scroll end (never a fixed scrollbar-height padding in CSS).
10511051
- **Drag pipeline** (`useDragAndDrop`): mousedown or touchstart on a bar (touches become a mouse-like event via `toMouseLikeEvent`) → live DOM movement with cell snapping (week/month) and a dependency lag indicator → on drop, `commitDragUpdate` computes new dates from pixels, awaits `confirmAction()` for whole-project moves (cancel snaps the bar back), snapshots undo state, clones and mutates the project tree (cascading children + assignments), updates the store optimistically, then persists via batched `PendingUpdate`s (`savePendingUpdates`). **Any persist error → reload all projects + clear undo.** The document `mousemove`/`mouseup` **and `touchmove`/`touchend`/`touchcancel`** listeners (here and in `useResize`) are attached at drag start and removed on mouse-up/touch-end and on unmount — never left on the document between drags. Touch: the bars hand the hooks a mouse-like event built by `utils/touchEvents.ts` (`toMouseLikeEvent`, an explicit object — never spread a SyntheticEvent, its methods are lost; its `preventDefault` is a no-op for the passive touchstart), and every touch start waits for a 5 px move first (`armTouchGesture` for project, assignment and resize-handle touches; the PhaseBar body has its own threshold) so a wobbly tap never commits a move; the non-passive `touchmove` listener calls `preventDefault()` only on interactive bars (React's root `touchstart` listener is passive, so only CSS `touch-action: none` on the *interactive* bars — the `interactive` class, set when a bar has drag/resize handlers, so read-only bars still scroll — plus that listener stop the timeline from scrolling), `touchend` is default-prevented so the browser sends no emulated click, and `touchcancel` restores the bar without saving (`cancelDrag`/`cancelResize`).
10521052
- **Auto-calculation semantics** (`gantt/utils/autoCalculation.ts`): dependencies auto-align **only at creation time**; moving an item does *not* cascade to its dependents (users keep manual lead/lag control). Moves do (a) expand/contract parents to the union of their children and (b) auto-fit project dates to phases. Staff-assignment updates must include `allocation` (backend requirement).
10531053
- **Critical path** (`components/gantt/utils/criticalPath.ts`): full CPM forward/backward pass, `totalFloat <= 0` = critical; loaded via dynamic import from `appStore.toggleCriticalPath` to avoid a circular dependency.
@@ -1082,7 +1082,7 @@ All backend↔frontend field mapping is centralized. The frontend model is mostl
10821082
| `useGanttColorBy` | Current site's Gantt "Colour by" (`{siteId, group, setGroup}`), read by every `ProjectTimeline` row and the `ColorByDropdown`; same rules as `useGanttGrouping` (primitive selectors, stale id → `null` without a write, no-op without a site). Import from `@/hooks/useGanttColorBy` |
10831083
| `useEscapeKey` | Escape-to-close for standalone dialogs that can't use the shared `Modal` (e.g. the admin-portal modals). Stands down while a `confirmAction()` request is pending, so Escape on the confirm never also closes the dialog that asked |
10841084
| `useCtrlScrollZoom` | Ctrl+wheel zoom keeping the date under the cursor fixed |
1085-
| `useScrollSync` | Bidirectional vertical scroll sync between two elements (used by GanttContainer and the Staff/Equipment/CrossSite/Archived views) |
1085+
| `useScrollSync` | Bidirectional vertical scroll sync between two elements (used by GanttContainer and the Staff/Equipment/CrossSite/Archived views). Three behaviours: no echo (a write remembers the value and ignores the follower's matching `scroll` event, so the follower never snaps the source back), a non-passive `wheel` handler that moves both panes in the same frame (Ctrl+wheel and horizontal deltas are left alone), and equalised scroll range — it measures both panes' natural `scrollHeight - clientHeight` and pads the shorter one (`ResizeObserver`, `window` resize fallback), which replaced the fixed 8 px bottom padding that only matched a classic horizontal scrollbar |
10861086
| `useWorkloadCalculation` | Per-cell staff workload for the Staff heatmap (allocations, vacations incl. recurring, visual states). Only absences whose type `counts_as_unavailable` (or untyped ones) zero capacity. An optional closure lookup (`ClosureLookup`, usually `getClosureOn` bound to the person's sites) zeroes capacity on site-closure days → `vacation`/`conflict` state, `closureName`/`closureColor`, tooltip *Site closed: …* |
10871087
| `useEquipmentOverlaps` / `useEquipmentTodayStatus` | Equipment double-booking detection / today-status (`blocked` > `booked` > available) — two hooks in one file |
10881088
| `useTagFilter` | `useSiteTagFilter()` (the current site's `viewStore.tagFilters` entry, shared by the Gantt, the Kanban board and `TagFilterDropdown`; ids of tags deleted or re-scoped away from the site are ignored on read and pruned on the next write) + `useTagFilteredProjects(projects)` |

‎UI_GUIDELINES.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,12 @@ from the inline `project.tags[].color`, which is stale during What-If.
619619
by those.
620620
- Bars take the item's saved colour (§2.6); milestones are diamonds; the critical path
621621
highlights bars with `totalFloat <= 0`.
622+
- The tree panel and the timeline body are two vertical scroll containers kept together by
623+
`useScrollSync` (also the Staff, Equipment, Cross-site and Archived views): wheel and
624+
trackpad move both panes in the same frame, `scroll` events (scrollbar drag, keyboard) mirror
625+
without echo, and the hook pads the pane with the shorter scroll range so both reach the same
626+
end. Never give one pane a fixed bottom padding to "match the scrollbar" — the scrollbar's
627+
height is 0 with overlay scrollbars, and the padding then misaligns the last rows.
622628
- A bar is draggable or resizable only when it has handlers; those bars get the `interactive`
623629
class, which sets `touch-action: none` and disables text selection. Read-only bars keep
624630
scrolling.

‎VERSION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.10.0
1+
1.10.1

‎frontend/src/components/gantt/ProjectPanel/ProjectPanel.module.css‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,8 +201,6 @@
201201
flex: 1;
202202
overflow-y: auto;
203203
overflow-x: hidden;
204-
/* Add 8px padding at bottom to match horizontal scrollbar in timeline */
205-
padding-bottom: 8px;
206204
}
207205

208206
.body::-webkit-scrollbar {

‎frontend/src/components/views/ArchivedView/ArchivedView.module.css‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@
3535
flex: 1;
3636
overflow-y: auto;
3737
overflow-x: hidden;
38-
padding-bottom: 8px;
3938
}
4039

4140
.panelContent::-webkit-scrollbar {

‎frontend/src/components/views/CrossSiteView/CrossSiteView.module.css‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@
3838
flex: 1;
3939
overflow-y: auto;
4040
overflow-x: hidden;
41-
padding-bottom: 8px;
4241
}
4342

4443
.panelBody::-webkit-scrollbar {

‎frontend/src/components/views/EquipmentView/EquipmentView.module.css‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,6 @@
423423
flex: 1;
424424
overflow-y: auto;
425425
overflow-x: hidden;
426-
padding-bottom: 8px;
427426
}
428427

429428
.panelBody::-webkit-scrollbar {

‎frontend/src/components/views/StaffView/StaffView.module.css‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,6 @@
8282
flex: 1;
8383
overflow-y: auto;
8484
overflow-x: hidden;
85-
padding-bottom: 8px;
8685
}
8786

8887
.panelBody::-webkit-scrollbar {

0 commit comments

Comments
 (0)