Skip to content

Commit d10b0a1

Browse files
authored
feat(gantt): snap single issue bar drags, resizes and drops to working days (#11060)
* feat(gantt): working-day span helpers and prevWorkingDay 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> * feat(gantt): snap drag, resize and drop previews to working days 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> * feat(tracker): snap Gantt drags to the project working days; move children 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> * feat(gantt): hold Shift while dragging to ignore working days 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> * test(gantt): make the working-day cap tests deterministic 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> --------- Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com> Co-authored-by: Michael Uray <michaeluray@users.noreply.github.com>
1 parent 5077d97 commit d10b0a1

25 files changed

Lines changed: 1195 additions & 53 deletions

‎packages/gantt/src/__tests__/drag-controller.test.ts‎

Lines changed: 422 additions & 2 deletions
Large diffs are not rendered by default.

‎packages/gantt/src/__tests__/working-days.test.ts‎

Lines changed: 176 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55

66
import {
77
isWorkingDay,
8+
findWorkingDay,
89
nextWorkingDay,
10+
prevWorkingDay,
911
addWorkingDays,
1012
workingDaysBetween,
1113
workingDayDelta,
@@ -17,7 +19,12 @@ import {
1719
fsReverseAnchor,
1820
ssReverseAnchor,
1921
ffReverseAnchor,
20-
sfReverseAnchor
22+
sfReverseAnchor,
23+
workingDaySpan,
24+
dueForSpan,
25+
startForSpan,
26+
workingDaysPerWeek,
27+
MAX_WORKING_SPAN_DAYS
2128
} from '../working-days'
2229
import type { WorkingCalendar } from '../types'
2330

@@ -28,6 +35,7 @@ const DAY_MS = 86_400_000
2835
const MON = Date.UTC(2026, 4, 18)
2936
const TUE = Date.UTC(2026, 4, 19)
3037
const WED = Date.UTC(2026, 4, 20)
38+
const THU = Date.UTC(2026, 4, 21)
3139
const FRI = Date.UTC(2026, 4, 22)
3240
const SAT = Date.UTC(2026, 4, 23)
3341
const SUN = Date.UTC(2026, 4, 24)
@@ -297,3 +305,170 @@ describe('FS/SS/FF/SF anchors — working-days mode (Mon-Fri)', () => {
297305
expect(fsReverseAnchor(MON2, 0, cfgMonFri)).toBe(FRI)
298306
})
299307
})
308+
309+
describe('prevWorkingDay', () => {
310+
it('returns the input midnight when it is a working day', () => {
311+
expect(prevWorkingDay(MON, cfgMonFri)).toBe(MON)
312+
})
313+
314+
it('Saturday rolls back to Friday', () => {
315+
expect(prevWorkingDay(SAT, cfgMonFri)).toBe(FRI)
316+
})
317+
318+
it('Sunday rolls back to Friday', () => {
319+
expect(prevWorkingDay(SUN, cfgMonFri)).toBe(FRI)
320+
})
321+
322+
it('skips a Friday holiday back to Thursday', () => {
323+
const cfgHolFri: WorkingCalendar = { weekdayMask: 0b0011111, holidays: [FRI] }
324+
expect(prevWorkingDay(FRI, cfgHolFri)).toBe(THU)
325+
expect(prevWorkingDay(SUN, cfgHolFri)).toBe(THU)
326+
})
327+
328+
it('falls back to the input midnight when no day is a working day', () => {
329+
expect(prevWorkingDay(SAT + 3 * 3_600_000, cfgEmpty)).toBe(SAT)
330+
})
331+
332+
it('normalizes a time-of-day input to UTC midnight', () => {
333+
expect(prevWorkingDay(SAT + 3 * 3_600_000, cfgMonFri)).toBe(FRI)
334+
})
335+
})
336+
337+
describe('workingDaySpan', () => {
338+
it('Mon..Fri is five working days', () => {
339+
expect(workingDaySpan(MON, FRI, cfgMonFri)).toBe(5)
340+
})
341+
342+
it('Fri..Mon spans two working days across the weekend', () => {
343+
expect(workingDaySpan(FRI, MON2, cfgMonFri)).toBe(2)
344+
})
345+
346+
it('a weekend-only bar counts as one working day', () => {
347+
expect(workingDaySpan(SAT, SUN, cfgMonFri)).toBe(1)
348+
})
349+
350+
it('a holiday inside the bar is not counted', () => {
351+
expect(workingDaySpan(MON, FRI, { weekdayMask: 0b0011111, holidays: [WED] })).toBe(4)
352+
})
353+
354+
it('clamps an inverted range to one', () => {
355+
expect(workingDaySpan(FRI, MON, cfgMonFri)).toBe(1)
356+
})
357+
})
358+
359+
describe('dueForSpan / startForSpan', () => {
360+
it('a three-working-day bar starting Thursday ends next Monday', () => {
361+
expect(dueForSpan(THU, 3, cfgMonFri)).toBe(MON2)
362+
})
363+
364+
it('a three-working-day bar ending Monday starts the previous Thursday', () => {
365+
expect(startForSpan(MON2, 3, cfgMonFri)).toBe(THU)
366+
})
367+
368+
it('startForSpan inverts dueForSpan for every weekday start and spans 1..10', () => {
369+
for (const s of [MON, TUE, WED, THU, FRI]) {
370+
for (let n = 1; n <= 10; n++) {
371+
expect(startForSpan(dueForSpan(s, n, cfgMonFri), n, cfgMonFri)).toBe(s)
372+
}
373+
}
374+
})
375+
376+
it('a span of one or less returns the input unchanged', () => {
377+
expect(dueForSpan(SAT, 1, cfgMonFri)).toBe(SAT)
378+
expect(dueForSpan(WED, 0, cfgMonFri)).toBe(WED)
379+
expect(startForSpan(SUN, 1, cfgMonFri)).toBe(SUN)
380+
expect(startForSpan(WED, -2, cfgMonFri)).toBe(WED)
381+
})
382+
})
383+
384+
describe('workingDaysPerWeek', () => {
385+
it('counts the active weekdays of the mask', () => {
386+
expect(workingDaysPerWeek({ weekdayMask: 31, holidays: [] })).toBe(5)
387+
expect(workingDaysPerWeek({ weekdayMask: 63, holidays: [] })).toBe(6)
388+
expect(workingDaysPerWeek({ weekdayMask: 127, holidays: [] })).toBe(7)
389+
expect(workingDaysPerWeek({ weekdayMask: 0, holidays: [] })).toBe(0)
390+
})
391+
})
392+
393+
describe('working-day helpers — degenerate input stays bounded', () => {
394+
// 70 consecutive holidays from Mon May 18: longer than the 60-day bail.
395+
const allHolidays: WorkingCalendar = {
396+
weekdayMask: 0b1111111,
397+
holidays: Array.from({ length: 70 }, (_, i) => MON + i * DAY_MS)
398+
}
399+
400+
it('nextWorkingDay / prevWorkingDay fall back to the input midnight when holidays cover the window', () => {
401+
expect(nextWorkingDay(MON, allHolidays)).toBe(MON)
402+
expect(prevWorkingDay(MON + 69 * DAY_MS, allHolidays)).toBe(MON + 69 * DAY_MS)
403+
})
404+
405+
it('findWorkingDay reports a failed search instead of a non-working day', () => {
406+
expect(findWorkingDay(MON, 1, allHolidays)).toBeUndefined()
407+
expect(findWorkingDay(MON + 69 * DAY_MS, -1, allHolidays)).toBeUndefined()
408+
expect(findWorkingDay(SAT, 1, cfgEmpty)).toBeUndefined()
409+
expect(findWorkingDay(NaN, 1, cfgMonFri)).toBeUndefined()
410+
// Within the window it finds the working day just past the blackout.
411+
expect(findWorkingDay(MON + 20 * DAY_MS, 1, allHolidays)).toBe(MON + 70 * DAY_MS)
412+
expect(findWorkingDay(MON + 50 * DAY_MS, -1, allHolidays)).toBe(MON - DAY_MS)
413+
})
414+
415+
it('findWorkingDay snaps forward / backward like nextWorkingDay / prevWorkingDay', () => {
416+
expect(findWorkingDay(SAT + 3 * 3_600_000, 1, cfgMonFri)).toBe(MON2)
417+
expect(findWorkingDay(SAT + 3 * 3_600_000, -1, cfgMonFri)).toBe(FRI)
418+
expect(findWorkingDay(WED + 3 * 3_600_000, -1, cfgMonFri)).toBe(WED)
419+
})
420+
421+
// No wall-clock assertions: every step of the walk moves the cursor by one
422+
// calendar day, so the distance of the result from the input is an exact,
423+
// runner-independent count of the loop iterations.
424+
it('addWorkingDays caps a huge finite step count at MAX_WORKING_SPAN_DAYS', () => {
425+
expect(addWorkingDays(MON, Number.MAX_SAFE_INTEGER, cfgAllDays)).toBe(MON + MAX_WORKING_SPAN_DAYS * DAY_MS)
426+
expect(addWorkingDays(MON, -Number.MAX_SAFE_INTEGER, cfgAllDays)).toBe(MON - MAX_WORKING_SPAN_DAYS * DAY_MS)
427+
expect(addWorkingDays(MON, Number.MAX_VALUE, cfgMonFri)).toBe(addWorkingDays(MON, MAX_WORKING_SPAN_DAYS, cfgMonFri))
428+
})
429+
430+
it('addWorkingDays stops after |n| × 7 + 60 calendar days without working weekdays', () => {
431+
expect(addWorkingDays(MON, 3, cfgEmpty)).toBe(MON + (3 * 7 + 60) * DAY_MS)
432+
expect(addWorkingDays(MON, -3, cfgEmpty)).toBe(MON - (3 * 7 + 60) * DAY_MS)
433+
// A huge step count is capped first, so the walk is bounded by the cap.
434+
expect(addWorkingDays(MON, 1e15, cfgEmpty)).toBe(MON + (MAX_WORKING_SPAN_DAYS * 7 + 60) * DAY_MS)
435+
// A holiday blackout is walked through and the remaining steps still count.
436+
expect(addWorkingDays(MON + 69 * DAY_MS, -10, allHolidays)).toBe(MON - 10 * DAY_MS)
437+
})
438+
439+
it('addWorkingDays treats a non-finite step count as zero', () => {
440+
expect(addWorkingDays(MON, Infinity, cfgMonFri)).toBe(MON)
441+
expect(addWorkingDays(MON, -Infinity, cfgMonFri)).toBe(MON)
442+
expect(addWorkingDays(MON, NaN, cfgMonFri)).toBe(MON)
443+
})
444+
445+
it('workingDaySpan returns 1 for non-finite or inverted input', () => {
446+
expect(workingDaySpan(MON, Infinity, cfgMonFri)).toBe(1)
447+
expect(workingDaySpan(-Infinity, MON, cfgMonFri)).toBe(1)
448+
expect(workingDaySpan(NaN, MON, cfgMonFri)).toBe(1)
449+
expect(workingDaySpan(Number.MAX_SAFE_INTEGER, 0, cfgMonFri)).toBe(1)
450+
})
451+
452+
it('workingDaySpan counts at most MAX_WORKING_SPAN_DAYS calendar days of a huge range', () => {
453+
expect(workingDaySpan(0, Number.MAX_SAFE_INTEGER, cfgAllDays)).toBe(MAX_WORKING_SPAN_DAYS)
454+
expect(workingDaySpan(0, Number.MAX_SAFE_INTEGER, cfgEmpty)).toBe(1)
455+
})
456+
457+
it('dueForSpan / startForSpan treat a non-finite span as one day', () => {
458+
expect(dueForSpan(WED, Infinity, cfgMonFri)).toBe(WED)
459+
expect(dueForSpan(WED, NaN, cfgMonFri)).toBe(WED)
460+
expect(startForSpan(WED, Infinity, cfgMonFri)).toBe(WED)
461+
})
462+
463+
it('dueForSpan / startForSpan clamp a huge span and terminate without working weekdays', () => {
464+
expect(dueForSpan(MON, 1e12, cfgAllDays)).toBe(MON + (MAX_WORKING_SPAN_DAYS - 1) * DAY_MS)
465+
expect(startForSpan(MON, 1e12, cfgAllDays)).toBe(MON - (MAX_WORKING_SPAN_DAYS - 1) * DAY_MS)
466+
expect(dueForSpan(MON, 1e12, cfgEmpty)).toBe(MON + ((MAX_WORKING_SPAN_DAYS - 1) * 7 + 60) * DAY_MS)
467+
expect(startForSpan(MON, 3, cfgEmpty)).toBe(MON - (2 * 7 + 60) * DAY_MS)
468+
})
469+
470+
it('dueForSpan / startForSpan return a non-finite anchor unchanged', () => {
471+
expect(dueForSpan(NaN, 3, cfgMonFri)).toBeNaN()
472+
expect(startForSpan(Infinity, 3, cfgMonFri)).toBe(Infinity)
473+
})
474+
})

0 commit comments

Comments
 (0)