Skip to content

feat(gantt): snap single issue bar drags, resizes and drops to working days - #11060

Merged
ArtyomSavchenko merged 5 commits into
hcengineering:developfrom
MichaelUray:feat/gantt-working-day-drag
Oct 7, 2026
Merged

ArtyomSavchenko merged 5 commits into
hcengineering:developfrom
MichaelUray:feat/gantt-working-day-drag

Conversation

@MichaelUray

@MichaelUray MichaelUray commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #11054, which listed drag snapping as out of scope. Single issue bars
only: bulk co-drags and milestones are unchanged (see below).

Problem

With a working-days calendar on the project, moving or resizing a bar still works in
calendar days: the preview and the committed dates keep the bar's calendar-day length
and can land on a Saturday, a Sunday or a holiday. Dropping an unscheduled issue,
shifting a bar with the arrow keys and moving a parent together with its children
behave the same way. The cascade already places successors on working days (#10992
review, #11054), so a dragged predecessor was the one bar in a chain that could end
on a weekend.

Change

@hcengineering/gantt (engine, domain-neutral):

  • prevWorkingDay, findWorkingDay (the nearest working day in a direction, or
    undefined when none lies within the 60-day search window), workingDaySpan,
    dueForSpan, startForSpan and workingDaysPerWeek next to the existing
    working-day helpers.
  • reduce() takes an optional WorkingCalendar. With a calendar, a body drag lands
    the start on the nearest working day in the drag direction and keeps the bar's length
    in working days. The start handle rounds up to the next working day and the end
    handle rounds down to the previous one, so a handle is never extended onto a
    non-working day: with the pointer on a weekend the start waits on Monday and the end
    stays on Friday. A dropped unscheduled issue starts on the next working day. A
    zero-delta move leaves a bar untouched even if it sits on a non-working day, so a
    click without movement still commits nothing. Without a calendar the reducer is
    unchanged.
  • The day-stepping loops stay bounded for degenerate input. The span helpers cap
    ranges and spans at MAX_WORKING_SPAN_DAYS (about 100 years) and count non-finite
    or inverted ranges as one working day. addWorkingDays treats a non-finite step
    count as zero instead of looping forever and caps a huge finite one (e.g. a
    malformed stored lag) at the same bound. The reducer falls back to calendar days
    for a calendar without any working weekday, for a bar whose origin is not a
    finite range within the cap, and when no working day is within reach of the
    pointer (a holiday blackout longer than the search window), so a non-working day
    is never presented as a snapped result.

Tracker adapter:

  • GanttView passes the project calendar to the reducer for single issue bars.
  • A parent drag shifts the children by the parent's move measured in working days; the
    arrow keys move a bar by working days (Shift = one week = the number of active
    weekdays; the help overlay now says "±1 week").

Per-drag override: hold Shift for calendar days

Sometimes one bar really has to sit on a weekend. Holding Shift during a single
issue bar drag, resize or sidebar drop suspends the snapping for that gesture: the
preview and the committed dates move in calendar days, exactly as in a project
without a working-days calendar, and the children of a dragged parent shift by the
same calendar-day delta. Pressing or releasing Shift mid-drag updates the preview
immediately (also without moving the pointer), so the decision can be made before
dropping. While snapping is suspended a small "Calendar days" hint is shown under the
date pill, and the keyboard help (?) lists "Shift + drag". Dependency cascades
triggered by the drop still follow the project calendar; Alt keeps bypassing them.

Why Shift and not Alt: Alt is already read at release to bypass the cascade
simulation ("Alt + drag" in the help), and several Linux window managers grab
Alt+drag to move windows, so the gesture would not reach the browser there. Cmd/Ctrl
toggle the multi-selection on click and Ctrl+click opens the context menu on macOS.
Shift only range-selects on a plain click, so it is free once a drag runs. A body
drag therefore still starts without a modifier (Shift at pointer-down keeps meaning
range selection); Shift is pressed once the bar moves. Resize handles can be grabbed
with Shift already held.

Implementation: mousemove carries an optional calendarDays flag; the reducer then
computes the preview without the calendar and marks the state snapSuspended. The
flag is set only when a calendar would otherwise apply, so legacy projects, co-drags
and milestones are unaffected and show no hint. dragCalendar(state, calendar) returns
the calendar a commit should use for the drag's own arithmetic (the parent-drag child
shift). GanttView keeps the last pointer position and re-dispatches it on Shift
keydown/keyup while a drag is active; modifierSyncMove builds that replayed move.
The release re-reads shiftKey from the pointer event and a window blur counts as
releasing Shift, so a Shift let go outside the browser window cannot leave the drop
committing calendar days. New strings GanttDragCalendarDays and
GanttHelpCalendarDaysDrag are added for all tracker locales.

Not changed in this PR:

  • Bulk co-drag keeps its shared raw delta. Its hard-stop window is computed in
    milliseconds, so snapping each member to working days could breach a predecessor
    constraint the window was meant to protect; moving the window to working days is a
    separate change.
  • Milestones keep calendar-day drags; whether a milestone may sit on a non-working day
    is a product question.

Projects without workingDaysConfig are not affected: every new branch is behind
calendar !== undefined, and the legacy paths are pinned by tests with fixed values.

A bar stored entirely on non-working days becomes a one-working-day bar when it is moved.

Tests

  • working-days.test.ts: the new helpers, including a Friday holiday and the
    startForSpan ∘ dueForSpan round trip, plus degenerate input (no working weekday,
    holidays covering the search window, non-finite and huge ranges, spans and step
    counts such as Number.MAX_SAFE_INTEGER).
  • drag-controller.test.ts: body drag onto Saturday/Sunday/holiday in both directions,
    working-day span across a weekend, zero-delta on a non-working origin, both resize
    handles with their clamps, unscheduled drop on a Saturday, co-drag unchanged with the
    calendar passed, fixed legacy values for every branch without a calendar, and the
    calendar-day fallback for degenerate calendars and bars and for a 150-day holiday
    blackout (body drag, both resize handles, unscheduled drop; a sweep asserts no
    snapped preview ever lands in the blackout).
  • scheduler-cascade.test.ts: shiftScheduleDays, shiftWithPrimary and
    keyboardWeekStep with fixed values in both modes.
  • Shift override (drag-controller.test.ts, scheduler-cascade.test.ts): body drag
    onto Saturday stays on Saturday with the calendar length, both resize handles and the
    unscheduled drop follow the pointer onto the weekend, pressing and releasing Shift
    mid-drag switches the preview at the same pointer position (body and resize), no
    effect and no flag without a calendar or for a co-drag, dragCalendar, and a parent
    drag whose child moves by working days without Shift and by calendar days with it.
    modifierSyncMove: a blur or a release without Shift un-suspends the drag at the last
    pointer position so the commit snaps again, a release still holding Shift keeps the
    calendar-day preview, the unscheduled drop keeps its canvas position, and nothing is
    replayed without an active drag or a prior move.

Open question

The body drag snaps to the nearest working day in the drag direction (Monday − 1 day
→ Friday) rather than always rounding up; happy to change that if you prefer.

Add prevWorkingDay (mirror of nextWorkingDay), findWorkingDay (the
nearest working day in a direction, or undefined when none lies within
the 60-day search window, so a caller can tell a failed search from a
result), workingDaySpan (inclusive working-day length of a bar, never
below 1), dueForSpan / startForSpan (the far end of a bar with a given
working-day span) and workingDaysPerWeek (active weekdays of the mask).

The helpers stay bounded for degenerate input: non-finite or inverted
ranges count as one working day, ranges, spans and addWorkingDays step
counts are capped at MAX_WORKING_SPAN_DAYS (about 100 years), and
addWorkingDays treats a non-finite step count as zero instead of looping
forever.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
reduce() takes an optional WorkingCalendar. With a calendar, a single-bar
body drag lands the start on the nearest working day in the drag direction
and keeps the bar's length in working days; the start handle rounds up to
the next working day and the end handle rounds down to the previous one;
a dropped unscheduled issue starts on the next working day and lasts two
working days. A zero-delta move leaves the bar untouched. A co-drag keeps
its shared raw delta, and without a calendar the reducer is unchanged.

Degenerate calendars fall back to calendar days: a calendar without any
working weekday, a bar whose origin is not a finite range of at most
MAX_WORKING_SPAN_DAYS days, and a preview with no working day within
reach (a holiday blackout longer than the 60-day search window), so a
non-working day is never presented as a snapped result.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…ldren and keyboard shifts by working days

GanttView passes the project calendar to the drag reducer for single
issue bars; milestones and bulk co-drags keep calendar-day drags. A
parent drag shifts its children by the parent's move measured in
working days (shiftWithPrimary), and the arrow keys move a bar by
working days (shiftScheduleDays), with Shift moving one week = the
number of active weekdays (keyboardWeekStep). The help overlay now
says "±1 week". Without a project calendar every path keeps its
calendar-day arithmetic.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
@ArtyomSavchenko

ArtyomSavchenko commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

@MichaelUray Please take a look at the following review comments:

  1. A calendar with no working weekdays is only guarded in the drag logic. The drag code falls back to calendar days in this case, but the new tracker helpers don't. I checked the helpers with such a calendar:
    Arrow ←/→ calls addWorkingDays(t, ±1), which runs into its safety limit and returns a date about 67 days away. Before this PR it moved 1 day.
    Shift+Arrow gives keyboardWeekStep = 0, so shiftFocused(0) runs a commit that changes nothing.
    Dragging a parent: the parent moves in calendar days (fallback), but workingDayDelta returns 0, so its children stay where they are.
    The project settings editor won't let you switch off the last weekday, so this only comes from data written through the API or a migration. The simplest fix is to normalise once: set effectiveCalendar to undefined when workingDaysPerWeek(cfg) === 0, so every path uses the same rule.

  2. The resize tooltip still counts calendar days. GanttResizeOverlay.durationTooltipParams shows "{from} days → {to} days" using calendar days. A handle that just snapped across a weekend will show +3 days for what is +1 working day. It would read better in working days when a calendar is active.

  3. Help text. The PR changes the Shift line to "±1 week", but the plain arrows line still says "Move selected issue ±1 day", which now means a working day.

Holding Shift during a single issue bar drag, resize or sidebar drop
suspends working-day snapping for that gesture: the preview and the
committed dates move in calendar days, exactly as in a project without
working days, and the children of a dragged parent shift by the same
calendar-day delta. Pressing or releasing Shift mid-drag updates the
preview at once, so the user can decide before dropping. A "Calendar
days" hint is shown under the date pill while snapping is suspended.
The commit uses the modifier state of the release event itself, and
losing window focus counts as releasing Shift, so a Shift let go outside
the window cannot leave the drag committing calendar days.

Shift is the free modifier during a drag: Alt is already read at release
to bypass the cascade simulation (and Alt+drag is grabbed by several
Linux window managers), Cmd/Ctrl toggle the selection, and Shift only
range-selects on a plain click. A body drag still has to start without
a modifier; Shift is pressed once the bar moves.

The reducer takes `calendarDays` on `mousemove` and marks the state
`snapSuspended` only when a calendar would otherwise apply; legacy mode,
co-drags and milestones are unchanged. `dragCalendar(state, calendar)`
gives the commit the calendar for the drag's own arithmetic.
`modifierSyncMove(state, calendarDays, lastMove)` yields the move that
re-syncs a running drag with the modifier at the last pointer position.
Dependency cascades keep following the project calendar. The keyboard
help lists the new shortcut; strings are added for all tracker locales.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
@MichaelUray

Copy link
Copy Markdown
Contributor Author

Added a per-drag override in the last commit: holding Shift during a single issue bar drag, resize or sidebar drop suspends working-day snapping for that gesture, so a bar can be placed on a weekend or holiday when it really has to. The preview and the committed dates then move in calendar days (children of a dragged parent shift by the same calendar-day delta), a small "Calendar days" hint appears under the date pill, and pressing or releasing Shift mid-drag updates the preview immediately. Dependency cascades still follow the project calendar.

Why Shift rather than Alt: Alt is already read at release to bypass the cascade simulation, and several Linux window managers grab Alt+drag for moving windows. Cmd/Ctrl toggle the selection, while Shift only range-selects on a plain click, so it is free once a drag is running. The release re-reads shiftKey from the pointer event and a window blur counts as releasing Shift, so a Shift let go outside the browser cannot leave the drop in calendar-day mode.

Tests: reducer cases in drag-controller.test.ts (body drag, both resize handles and unscheduled drop with the override, toggling mid-drag, no effect without a calendar or for a co-drag, dragCalendar, modifierSyncMove for blur/release), plus a parent-drag child-shift case in scheduler-cascade.test.ts. The PR description is updated accordingly.

@ArtyomSavchenko

Copy link
Copy Markdown
Member

@MichaelUray Please check failed test:

  ...45 lines omitted...
      430 |
      431 |   it('addWorkingDays treats a non-finite step count as zero', () => {

      at Object.<anonymous> (src/__tests__/working-days.test.ts:428:29)


Test Suites: 1 failed, 23 passed, 24 total

The addWorkingDays cap test asserted a wall-clock bound (< 2000 ms) that
failed on a slower CI runner. Assert the exact results instead: each loop
step moves the cursor by one calendar day, so the distance of the result
from the input is a deterministic count of the iterations. Cover the
safety bound of calendars without working weekdays with small step counts
and drop the costly 70-holiday walk, which cuts the suite's slowest tests
from ~950 ms to ~250 ms.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
@MichaelUray

Copy link
Copy Markdown
Contributor Author

@ArtyomSavchenko Thanks. The test asserted a wall-clock bound (Date.now() - t0 < 2000), which a slower runner exceeded. Fixed in 2ce5d7d: the cap tests now assert exact results instead (each loop step moves one calendar day, so the distance from the input is a deterministic iteration count), and the most expensive degenerate-calendar case was replaced by smaller inputs, so these tests now take ~250 ms instead of ~950 ms locally. rush fast-format --branch develop passes with 0 errors on the branch.

@ArtyomSavchenko
ArtyomSavchenko merged commit d10b0a1 into hcengineering:develop Oct 7, 2026
12 of 13 checks passed
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.

2 participants