You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit d0dd979
Browse filesBrowse the repository at this point in the historyBrowse files
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>
-`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.
Copy file name to clipboardExpand all lines: CHANGELOG.md
+6Lines changed: 6 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -5,6 +5,12 @@ All notable changes to Milestone Planner are documented in this file.
5
5
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
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.
Copy file name to clipboardExpand all lines: CLAUDE.md
+2-2Lines changed: 2 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1047,7 +1047,7 @@ All backend↔frontend field mapping is centralized. The frontend model is mostl
1047
1047
-`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`.
1048
1048
-`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.
1049
1049
-**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).
1051
1051
- **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`).
1052
1052
-**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).
1053
1053
-**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
1082
1082
|`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`|
1083
1083
|`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 |
1084
1084
|`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|
1086
1086
|`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: …*|
1087
1087
|`useEquipmentOverlaps` / `useEquipmentTodayStatus`| Equipment double-booking detection / today-status (`blocked` > `booked` > available) — two hooks in one file |
1088
1088
|`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)`|
0 commit comments