Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
title: "Gantt two-pane scroll sync: no echo, wheel-driven, measured equal scroll range"
author: Claude
date: 2026-10-17
branch: fix/gantt-scroll-sync
cost: { minutes: 12, tokens: 78066 }
touches:
- CHANGELOG.md
- CLAUDE.md
- UI_GUIDELINES.md
- VERSION
- frontend/src/components/gantt/ProjectPanel/ProjectPanel.module.css
- frontend/src/components/views/ArchivedView/ArchivedView.module.css
- frontend/src/components/views/CrossSiteView/CrossSiteView.module.css
- frontend/src/components/views/EquipmentView/EquipmentView.module.css
- frontend/src/components/views/StaffView/StaffView.module.css
- frontend/src/hooks/__tests__/useScrollSync.test.ts
---

## Decided
- `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.
- 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.

## Tried and ruled out
- 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.
- 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.
- 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.

## Watch out
- 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.
- Mouse-wheel notches jump instantly on the wheel path (no smooth scrolling); accepted.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to Milestone Planner are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/).

## [1.10.1] - 2026-10-17

### Fixed
- 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.
- 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.

## [1.10.0] - 2026-10-17

### Changed
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1047,7 +1047,7 @@ All backend↔frontend field mapping is centralized. The frontend model is mostl
- `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`.
- `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.
- **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).
- Scroll sync: `TimelineScrollContext` (horizontal, direct DOM with feedback-loop guard) across Gantt/Staff/Equipment timelines; `useScrollSync` (vertical) between tree panel and timeline body.
- 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).
- **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`).
- **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).
- **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.
Expand Down Expand Up @@ -1082,7 +1082,7 @@ All backend↔frontend field mapping is centralized. The frontend model is mostl
| `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` |
| `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 |
| `useCtrlScrollZoom` | Ctrl+wheel zoom keeping the date under the cursor fixed |
| `useScrollSync` | Bidirectional vertical scroll sync between two elements (used by GanttContainer and the Staff/Equipment/CrossSite/Archived views) |
| `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 |
| `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: …* |
| `useEquipmentOverlaps` / `useEquipmentTodayStatus` | Equipment double-booking detection / today-status (`blocked` > `booked` > available) — two hooks in one file |
| `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)` |
Expand Down
6 changes: 6 additions & 0 deletions UI_GUIDELINES.md
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,12 @@ from the inline `project.tags[].color`, which is stale during What-If.
by those.
- Bars take the item's saved colour (§2.6); milestones are diamonds; the critical path
highlights bars with `totalFloat <= 0`.
- The tree panel and the timeline body are two vertical scroll containers kept together by
`useScrollSync` (also the Staff, Equipment, Cross-site and Archived views): wheel and
trackpad move both panes in the same frame, `scroll` events (scrollbar drag, keyboard) mirror
without echo, and the hook pads the pane with the shorter scroll range so both reach the same
end. Never give one pane a fixed bottom padding to "match the scrollbar" — the scrollbar's
height is 0 with overlay scrollbars, and the padding then misaligns the last rows.
- A bar is draggable or resizable only when it has handlers; those bars get the `interactive`
class, which sets `touch-action: none` and disables text selection. Read-only bars keep
scrolling.
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.10.0
1.10.1
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,6 @@
flex: 1;
overflow-y: auto;
overflow-x: hidden;
/* Add 8px padding at bottom to match horizontal scrollbar in timeline */
padding-bottom: 8px;
}

.body::-webkit-scrollbar {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding-bottom: 8px;
}

.panelContent::-webkit-scrollbar {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding-bottom: 8px;
}

.panelBody::-webkit-scrollbar {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,6 @@
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding-bottom: 8px;
}

.panelBody::-webkit-scrollbar {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding-bottom: 8px;
}

.panelBody::-webkit-scrollbar {
Expand Down
Loading
Loading