From 9c58dd6b35395ca336f6f807cf7f300546c73b33 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Tue, 6 Oct 2026 09:27:24 +0000 Subject: [PATCH 1/5] 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 --- .../gantt/src/__tests__/working-days.test.ts | 169 +++++++++++++++++- packages/gantt/src/working-days.ts | 101 +++++++++-- 2 files changed, 256 insertions(+), 14 deletions(-) diff --git a/packages/gantt/src/__tests__/working-days.test.ts b/packages/gantt/src/__tests__/working-days.test.ts index c2d2ecf2a61..a307059687f 100644 --- a/packages/gantt/src/__tests__/working-days.test.ts +++ b/packages/gantt/src/__tests__/working-days.test.ts @@ -5,7 +5,9 @@ import { isWorkingDay, + findWorkingDay, nextWorkingDay, + prevWorkingDay, addWorkingDays, workingDaysBetween, workingDayDelta, @@ -17,7 +19,12 @@ import { fsReverseAnchor, ssReverseAnchor, ffReverseAnchor, - sfReverseAnchor + sfReverseAnchor, + workingDaySpan, + dueForSpan, + startForSpan, + workingDaysPerWeek, + MAX_WORKING_SPAN_DAYS } from '../working-days' import type { WorkingCalendar } from '../types' @@ -28,6 +35,7 @@ const DAY_MS = 86_400_000 const MON = Date.UTC(2026, 4, 18) const TUE = Date.UTC(2026, 4, 19) const WED = Date.UTC(2026, 4, 20) +const THU = Date.UTC(2026, 4, 21) const FRI = Date.UTC(2026, 4, 22) const SAT = Date.UTC(2026, 4, 23) const SUN = Date.UTC(2026, 4, 24) @@ -297,3 +305,162 @@ describe('FS/SS/FF/SF anchors — working-days mode (Mon-Fri)', () => { expect(fsReverseAnchor(MON2, 0, cfgMonFri)).toBe(FRI) }) }) + +describe('prevWorkingDay', () => { + it('returns the input midnight when it is a working day', () => { + expect(prevWorkingDay(MON, cfgMonFri)).toBe(MON) + }) + + it('Saturday rolls back to Friday', () => { + expect(prevWorkingDay(SAT, cfgMonFri)).toBe(FRI) + }) + + it('Sunday rolls back to Friday', () => { + expect(prevWorkingDay(SUN, cfgMonFri)).toBe(FRI) + }) + + it('skips a Friday holiday back to Thursday', () => { + const cfgHolFri: WorkingCalendar = { weekdayMask: 0b0011111, holidays: [FRI] } + expect(prevWorkingDay(FRI, cfgHolFri)).toBe(THU) + expect(prevWorkingDay(SUN, cfgHolFri)).toBe(THU) + }) + + it('falls back to the input midnight when no day is a working day', () => { + expect(prevWorkingDay(SAT + 3 * 3_600_000, cfgEmpty)).toBe(SAT) + }) + + it('normalizes a time-of-day input to UTC midnight', () => { + expect(prevWorkingDay(SAT + 3 * 3_600_000, cfgMonFri)).toBe(FRI) + }) +}) + +describe('workingDaySpan', () => { + it('Mon..Fri is five working days', () => { + expect(workingDaySpan(MON, FRI, cfgMonFri)).toBe(5) + }) + + it('Fri..Mon spans two working days across the weekend', () => { + expect(workingDaySpan(FRI, MON2, cfgMonFri)).toBe(2) + }) + + it('a weekend-only bar counts as one working day', () => { + expect(workingDaySpan(SAT, SUN, cfgMonFri)).toBe(1) + }) + + it('a holiday inside the bar is not counted', () => { + expect(workingDaySpan(MON, FRI, { weekdayMask: 0b0011111, holidays: [WED] })).toBe(4) + }) + + it('clamps an inverted range to one', () => { + expect(workingDaySpan(FRI, MON, cfgMonFri)).toBe(1) + }) +}) + +describe('dueForSpan / startForSpan', () => { + it('a three-working-day bar starting Thursday ends next Monday', () => { + expect(dueForSpan(THU, 3, cfgMonFri)).toBe(MON2) + }) + + it('a three-working-day bar ending Monday starts the previous Thursday', () => { + expect(startForSpan(MON2, 3, cfgMonFri)).toBe(THU) + }) + + it('startForSpan inverts dueForSpan for every weekday start and spans 1..10', () => { + for (const s of [MON, TUE, WED, THU, FRI]) { + for (let n = 1; n <= 10; n++) { + expect(startForSpan(dueForSpan(s, n, cfgMonFri), n, cfgMonFri)).toBe(s) + } + } + }) + + it('a span of one or less returns the input unchanged', () => { + expect(dueForSpan(SAT, 1, cfgMonFri)).toBe(SAT) + expect(dueForSpan(WED, 0, cfgMonFri)).toBe(WED) + expect(startForSpan(SUN, 1, cfgMonFri)).toBe(SUN) + expect(startForSpan(WED, -2, cfgMonFri)).toBe(WED) + }) +}) + +describe('workingDaysPerWeek', () => { + it('counts the active weekdays of the mask', () => { + expect(workingDaysPerWeek({ weekdayMask: 31, holidays: [] })).toBe(5) + expect(workingDaysPerWeek({ weekdayMask: 63, holidays: [] })).toBe(6) + expect(workingDaysPerWeek({ weekdayMask: 127, holidays: [] })).toBe(7) + expect(workingDaysPerWeek({ weekdayMask: 0, holidays: [] })).toBe(0) + }) +}) + +describe('working-day helpers — degenerate input stays bounded', () => { + // 70 consecutive holidays from Mon May 18: longer than the 60-day bail. + const allHolidays: WorkingCalendar = { + weekdayMask: 0b1111111, + holidays: Array.from({ length: 70 }, (_, i) => MON + i * DAY_MS) + } + + it('nextWorkingDay / prevWorkingDay fall back to the input midnight when holidays cover the window', () => { + expect(nextWorkingDay(MON, allHolidays)).toBe(MON) + expect(prevWorkingDay(MON + 69 * DAY_MS, allHolidays)).toBe(MON + 69 * DAY_MS) + }) + + it('findWorkingDay reports a failed search instead of a non-working day', () => { + expect(findWorkingDay(MON, 1, allHolidays)).toBeUndefined() + expect(findWorkingDay(MON + 69 * DAY_MS, -1, allHolidays)).toBeUndefined() + expect(findWorkingDay(SAT, 1, cfgEmpty)).toBeUndefined() + expect(findWorkingDay(NaN, 1, cfgMonFri)).toBeUndefined() + // Within the window it finds the working day just past the blackout. + expect(findWorkingDay(MON + 20 * DAY_MS, 1, allHolidays)).toBe(MON + 70 * DAY_MS) + expect(findWorkingDay(MON + 50 * DAY_MS, -1, allHolidays)).toBe(MON - DAY_MS) + }) + + it('findWorkingDay snaps forward / backward like nextWorkingDay / prevWorkingDay', () => { + expect(findWorkingDay(SAT + 3 * 3_600_000, 1, cfgMonFri)).toBe(MON2) + expect(findWorkingDay(SAT + 3 * 3_600_000, -1, cfgMonFri)).toBe(FRI) + expect(findWorkingDay(WED + 3 * 3_600_000, -1, cfgMonFri)).toBe(WED) + }) + + it('addWorkingDays caps a huge finite step count and finishes quickly', () => { + const t0 = Date.now() + expect(addWorkingDays(MON, Number.MAX_SAFE_INTEGER, cfgAllDays)).toBe(MON + MAX_WORKING_SPAN_DAYS * DAY_MS) + expect(addWorkingDays(MON, -Number.MAX_SAFE_INTEGER, cfgAllDays)).toBe(MON - MAX_WORKING_SPAN_DAYS * DAY_MS) + expect(addWorkingDays(MON, Number.MAX_VALUE, cfgMonFri)).toBe(addWorkingDays(MON, MAX_WORKING_SPAN_DAYS, cfgMonFri)) + expect(Number.isFinite(addWorkingDays(MON, 1e15, cfgEmpty))).toBe(true) + expect(Number.isFinite(addWorkingDays(MON, -1e15, allHolidays))).toBe(true) + expect(Date.now() - t0).toBeLessThan(2000) + }) + + it('addWorkingDays treats a non-finite step count as zero', () => { + expect(addWorkingDays(MON, Infinity, cfgMonFri)).toBe(MON) + expect(addWorkingDays(MON, -Infinity, cfgMonFri)).toBe(MON) + expect(addWorkingDays(MON, NaN, cfgMonFri)).toBe(MON) + }) + + it('workingDaySpan returns 1 for non-finite or inverted input', () => { + expect(workingDaySpan(MON, Infinity, cfgMonFri)).toBe(1) + expect(workingDaySpan(-Infinity, MON, cfgMonFri)).toBe(1) + expect(workingDaySpan(NaN, MON, cfgMonFri)).toBe(1) + expect(workingDaySpan(Number.MAX_SAFE_INTEGER, 0, cfgMonFri)).toBe(1) + }) + + it('workingDaySpan counts at most MAX_WORKING_SPAN_DAYS calendar days of a huge range', () => { + expect(workingDaySpan(0, Number.MAX_SAFE_INTEGER, cfgAllDays)).toBe(MAX_WORKING_SPAN_DAYS) + expect(workingDaySpan(0, Number.MAX_SAFE_INTEGER, cfgEmpty)).toBe(1) + }) + + it('dueForSpan / startForSpan treat a non-finite span as one day', () => { + expect(dueForSpan(WED, Infinity, cfgMonFri)).toBe(WED) + expect(dueForSpan(WED, NaN, cfgMonFri)).toBe(WED) + expect(startForSpan(WED, Infinity, cfgMonFri)).toBe(WED) + }) + + it('dueForSpan / startForSpan clamp a huge span and terminate without working weekdays', () => { + expect(dueForSpan(MON, 1e12, cfgAllDays)).toBe(MON + (MAX_WORKING_SPAN_DAYS - 1) * DAY_MS) + expect(startForSpan(MON, 1e12, cfgAllDays)).toBe(MON - (MAX_WORKING_SPAN_DAYS - 1) * DAY_MS) + expect(Number.isFinite(dueForSpan(MON, 1e12, cfgEmpty))).toBe(true) + expect(Number.isFinite(startForSpan(MON, 1e12, cfgEmpty))).toBe(true) + }) + + it('dueForSpan / startForSpan return a non-finite anchor unchanged', () => { + expect(dueForSpan(NaN, 3, cfgMonFri)).toBeNaN() + expect(startForSpan(Infinity, 3, cfgMonFri)).toBe(Infinity) + }) +}) diff --git a/packages/gantt/src/working-days.ts b/packages/gantt/src/working-days.ts index ddf5c4ae572..7e31d8c4661 100644 --- a/packages/gantt/src/working-days.ts +++ b/packages/gantt/src/working-days.ts @@ -14,6 +14,15 @@ import type { WorkingCalendar } from './types' const DAY_MS = 86_400_000 +/** + * Upper bound (about 100 years) for the day-stepping loops behind the span + * helpers ({@link workingDaySpan}, {@link dueForSpan}, {@link startForSpan}) + * and for the step count of {@link addWorkingDays}. Keeps them bounded for + * degenerate input: a calendar without working weekdays, absurd date ranges, + * spans or lags. + */ +export const MAX_WORKING_SPAN_DAYS = 36_600 + /** * Canonical UTC-day normalizer: rounds a timestamp down to its UTC midnight. * Exported for the scheduler's day-granular resize gate, which must classify @@ -49,18 +58,45 @@ export function isWorkingDay (t: number, cfg: WorkingCalendar): boolean { } /** - * Returns the next working day ≥ `t` (UTC-midnight). If `t` itself is a - * working day, returns its midnight. Falls back to the input's midnight after - * 60 calendar-day iterations as a safety bail when no working days are - * configured (weekdayMask = 0 + no holidays granting any day). + * Calendar days {@link findWorkingDay} searches before it gives up. Covers any + * ordinary weekend/holiday run; a longer gap is treated as "no working day". */ -export function nextWorkingDay (t: number, cfg: WorkingCalendar): number { +const WORKING_DAY_SEARCH_DAYS = 60 + +/** + * The nearest working day (UTC-midnight) from `t` in `direction` (`1` = + * forward, `-1` = backward); `t`'s own midnight when it is a working day. + * Returns `undefined` when no working day lies within + * {@link WORKING_DAY_SEARCH_DAYS} calendar days — a calendar without working + * weekdays, a longer holiday blackout, or a non-finite `t` — so callers that + * must land on a working day can tell a failed search from a result. + */ +export function findWorkingDay (t: number, direction: 1 | -1, cfg: WorkingCalendar): number | undefined { + if (!Number.isFinite(t)) return undefined let cur = utcMidnight(t) - for (let i = 0; i < 60; i++) { + for (let i = 0; i < WORKING_DAY_SEARCH_DAYS; i++) { if (isWorkingDay(cur, cfg)) return cur - cur += DAY_MS + cur += direction * DAY_MS } - return utcMidnight(t) + return undefined +} + +/** + * Returns the next working day ≥ `t` (UTC-midnight). If `t` itself is a + * working day, returns its midnight. When {@link findWorkingDay} finds none, + * falls back to the input's midnight, which is then NOT a working day; use + * {@link findWorkingDay} where that must be detected. + */ +export function nextWorkingDay (t: number, cfg: WorkingCalendar): number { + return findWorkingDay(t, 1, cfg) ?? utcMidnight(t) +} + +/** + * Returns the latest working day ≤ `t` (UTC-midnight). Mirror of + * {@link nextWorkingDay}, with the same input-midnight fallback. + */ +export function prevWorkingDay (t: number, cfg: WorkingCalendar): number { + return findWorkingDay(t, -1, cfg) ?? utcMidnight(t) } /** @@ -68,15 +104,19 @@ export function nextWorkingDay (t: number, cfg: WorkingCalendar): number { * returns `t` unchanged (no auto-snap), so a user-pinned non-working date * is preserved when no shift is requested. * - * Safety: aborts after `|n| × 7 + 60` iterations to guard against - * non-progressing loops when all weekdays are non-working. + * Safety: `|n|` is capped at {@link MAX_WORKING_SPAN_DAYS}, and the walk + * aborts after `|n| × 7 + 60` iterations to guard against non-progressing + * loops when all weekdays are non-working. */ export function addWorkingDays (t: number, n: number, cfg: WorkingCalendar): number { - if (n === 0) return t + // A non-finite step count would never terminate (or never start); treat it + // like n = 0. A huge finite one (e.g. malformed persisted lag) is capped so + // the walk stays bounded. + if (n === 0 || !Number.isFinite(n)) return t const step = n > 0 ? DAY_MS : -DAY_MS - let remaining = Math.abs(n) + let remaining = Math.min(Math.abs(n), MAX_WORKING_SPAN_DAYS) let cur = t - let safety = Math.abs(n) * 7 + 60 + let safety = remaining * 7 + 60 while (remaining > 0 && safety-- > 0) { cur += step if (isWorkingDay(cur, cfg)) remaining-- @@ -105,6 +145,41 @@ export function workingDaysBetween (a: number, b: number, cfg: WorkingCalendar): return count * sign } +/** + * Inclusive working-day length of a bar, never below 1: a bar stored entirely + * on non-working days counts as one working day once it is moved. Bounded: + * non-finite or inverted input yields 1, and only the first + * {@link MAX_WORKING_SPAN_DAYS} calendar days of a longer bar are counted. + */ +export function workingDaySpan (start: number, due: number, cfg: WorkingCalendar): number { + if (!Number.isFinite(start) || !Number.isFinite(due) || due < start) return 1 + const end = Math.min(due, start + (MAX_WORKING_SPAN_DAYS - 1) * DAY_MS) + return Math.max(1, workingDaysBetween(start, end, cfg)) +} + +/** Clamps a span to `1..MAX_WORKING_SPAN_DAYS`; non-finite spans count as 1. */ +function boundedSpan (span: number): number { + if (!Number.isFinite(span)) return 1 + return Math.min(MAX_WORKING_SPAN_DAYS, Math.max(1, span)) +} + +/** Due date of a bar that starts at `start` and lasts `span` working days (inclusive). */ +export function dueForSpan (start: number, span: number, cfg: WorkingCalendar): number { + return addWorkingDays(start, boundedSpan(span) - 1, cfg) +} + +/** Start date of a bar that ends at `due` and lasts `span` working days (inclusive). */ +export function startForSpan (due: number, span: number, cfg: WorkingCalendar): number { + return addWorkingDays(due, -(boundedSpan(span) - 1), cfg) +} + +/** Number of active weekdays in the mask — "one week" in working days. */ +export function workingDaysPerWeek (cfg: WorkingCalendar): number { + let n = 0 + for (let bit = 0; bit < 7; bit++) if ((cfg.weekdayMask & (1 << bit)) !== 0) n++ + return n +} + /** * Signed working-day step count from `from` to `to`: counts working days in * the half-open interval that excludes `from` and includes `to` (mirrored From 8f796489b8d43bfc32a9bd40fc9a7c9c84948765 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Tue, 6 Oct 2026 09:29:04 +0000 Subject: [PATCH 2/5] 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 --- .../src/__tests__/drag-controller.test.ts | 263 +++++++++++++++++- packages/gantt/src/drag-controller.ts | 117 +++++++- 2 files changed, 365 insertions(+), 15 deletions(-) diff --git a/packages/gantt/src/__tests__/drag-controller.test.ts b/packages/gantt/src/__tests__/drag-controller.test.ts index 8713bfab79b..f8e187cb3a4 100644 --- a/packages/gantt/src/__tests__/drag-controller.test.ts +++ b/packages/gantt/src/__tests__/drag-controller.test.ts @@ -5,7 +5,7 @@ import { reduce } from '../drag-controller' import { createTimeScale, snapToUtcMidnight } from '../time-scale' -import type { DragState, DragTarget, GanttItem } from '../types' +import type { DragState, DragTarget, GanttItem, WorkingCalendar } from '../types' // Neutral stand-in for a tracker Issue/Milestone: the reducer only reads `_id`, // so the date/space fields are plain padding consumed by the test assertions. @@ -616,3 +616,264 @@ describe('drag-controller — bulk co-drag', () => { void snapToUtcMidnight // keep import alive }) }) + +describe('drag-controller — working-days calendar', () => { + // Week zoom = 14 px/day; dates in May 2026 (Mon 18 .. Mon 25). + const D = (day: number): number => Date.UTC(2026, 4, day) + const cfgMonFri: WorkingCalendar = { weekdayMask: 0b0011111, holidays: [] } + + const bodyState = (start: number, end: number): DragState => ({ + kind: 'dragging-body', + target: issueTarget, + originStart: start, + originEnd: end, + cursorStartX: 200, + previewStart: start, + previewEnd: end + }) + const leftState = (start: number, end: number): DragState => ({ + kind: 'resizing-left', + target: issueTarget, + originStart: start, + originEnd: end, + cursorStartX: 200, + previewStart: start + }) + const rightState = (start: number, end: number): DragState => ({ + kind: 'resizing-right', + target: issueTarget, + originStart: start, + originEnd: end, + cursorStartX: 200, + previewEnd: end + }) + const unscheduledState: DragState = { + kind: 'dragging-unscheduled', + target: issueTarget, + originStart: D(1), + originEnd: D(2), + cursorStartX: 100, + previewStart: D(1), + previewEnd: D(2), + hasCanvasTarget: false + } + const ts2 = createTimeScale('week', D(18)) + + const preview = (s: DragState): [number | undefined, number | undefined] => { + if (s.kind === 'dragging-body' || s.kind === 'dragging-unscheduled') return [s.previewStart, s.previewEnd] + if (s.kind === 'resizing-left') return [s.previewStart, undefined] + if (s.kind === 'resizing-right') return [undefined, s.previewEnd] + throw new Error(`unexpected state ${s.kind}`) + } + + it('body drag onto a Saturday lands on Monday and keeps three working days', () => { + const next = reduce(bodyState(D(18), D(20)), { type: 'mousemove', cursorX: 270 }, ts, cfgMonFri) + expect(preview(next)).toEqual([D(25), D(27)]) + }) + + it('body drag onto a working day keeps the working-day span across the weekend', () => { + const next = reduce(bodyState(D(18), D(20)), { type: 'mousemove', cursorX: 242 }, ts, cfgMonFri) + expect(preview(next)).toEqual([D(21), D(25)]) + }) + + it('body drag left onto a Sunday lands on the previous Friday', () => { + const next = reduce(bodyState(D(25), D(27)), { type: 'mousemove', cursorX: 186 }, ts, cfgMonFri) + expect(preview(next)).toEqual([D(22), D(26)]) + }) + + it('body drag onto a holiday lands on the next working day', () => { + const cfgHol: WorkingCalendar = { weekdayMask: 0b0011111, holidays: [D(21)] } + const next = reduce(bodyState(D(18), D(19)), { type: 'mousemove', cursorX: 242 }, ts, cfgHol) + expect(preview(next)).toEqual([D(22), D(25)]) + }) + + it('a zero-delta move leaves a bar on non-working days untouched', () => { + const next = reduce(bodyState(D(23), D(24)), { type: 'mousemove', cursorX: 200 }, ts, cfgMonFri) + expect(preview(next)).toEqual([D(23), D(24)]) + }) + + it('a weekend-only bar becomes a one-working-day bar once it is moved', () => { + const next = reduce(bodyState(D(23), D(24)), { type: 'mousemove', cursorX: 228 }, ts, cfgMonFri) + expect(preview(next)).toEqual([D(25), D(25)]) + }) + + it('the start handle rounds up to the next working day and never crosses originEnd', () => { + const at = (cursorX: number, start: number, end: number): number | undefined => + preview(reduce(leftState(start, end), { type: 'mousemove', cursorX }, ts, cfgMonFri))[0] + expect(at(186, D(18), D(19))).toBe(D(18)) // pointer on Sun 17 + expect(at(172, D(18), D(19))).toBe(D(18)) // pointer on Sat 16 + expect(at(158, D(18), D(19))).toBe(D(15)) // pointer on Fri 15 + expect(at(270, D(18), D(18))).toBe(D(18)) // Sat 23 → Mon 25, clamped to originEnd + }) + + it('the end handle rounds down to the previous working day and never crosses originStart', () => { + const at = (cursorX: number, start: number, end: number): number | undefined => + preview(reduce(rightState(start, end), { type: 'mousemove', cursorX }, ts, cfgMonFri))[1] + expect(at(228, D(18), D(21))).toBe(D(22)) // pointer on Sat 23 + expect(at(242, D(18), D(21))).toBe(D(22)) // pointer on Sun 24 + expect(at(256, D(18), D(21))).toBe(D(25)) // pointer on Mon 25 + expect(at(186, D(22), D(22))).toBe(D(22)) // Thu 21, clamped to originStart + }) + + it('an unscheduled issue dropped on a Saturday starts Monday and lasts two working days', () => { + const next = reduce(unscheduledState, { type: 'mousemove', cursorX: 200, canvasX: 5 * 14 }, ts2, cfgMonFri) + expect(preview(next)).toEqual([D(25), D(26)]) + if (next.kind !== 'dragging-unscheduled') throw new Error('expected dragging-unscheduled') + expect(next.hasCanvasTarget).toBe(true) + }) + + it('without a calendar every branch keeps its calendar-day result', () => { + expect(preview(reduce(bodyState(D(18), D(20)), { type: 'mousemove', cursorX: 270 }, ts))).toEqual([D(23), D(25)]) + expect(preview(reduce(leftState(D(18), D(19)), { type: 'mousemove', cursorX: 172 }, ts))[0]).toBe(D(16)) + expect(preview(reduce(rightState(D(18), D(21)), { type: 'mousemove', cursorX: 228 }, ts))[1]).toBe(D(23)) + expect(preview(reduce(unscheduledState, { type: 'mousemove', cursorX: 200, canvasX: 5 * 14 }, ts2))).toEqual([ + D(23), + D(24) + ]) + }) + + it('a co-drag ignores the calendar and keeps its shared clamped delta', () => { + const dragging: DragState = { + kind: 'dragging-body', + target: issueTarget, + originStart: D(18), + originEnd: D(20), + cursorStartX: 200, + previewStart: D(18), + previewEnd: D(20), + coDrag: { + anchorDeltaMs: 0, + members: [ + { issueId: issue._id, originStart: D(18), originEnd: D(20) }, + { issueId: 'issue-2', originStart: D(19), originEnd: D(22) } + ], + minDeltaMs: -2 * 86_400_000, + maxDeltaMs: 5 * 86_400_000 + } + } + // +6 days raw, clamped to +5 → the leader lands on Saturday 23. + const next = reduce(dragging, { type: 'mousemove', cursorX: 284 }, ts, cfgMonFri) + if (next.kind !== 'dragging-body') throw new Error('expected dragging-body') + expect(next.previewStart).toBe(D(23)) + expect(next.previewEnd).toBe(D(25)) + expect(next.coDrag?.anchorDeltaMs).toBe(5 * 86_400_000) + }) + + it('snaps in the drag direction, so reversing the drag does not jump', () => { + const right = reduce(bodyState(D(18), D(18)), { type: 'mousemove', cursorX: 214 }, ts, cfgMonFri) + expect(preview(right)).toEqual([D(19), D(19)]) + const left = reduce(right, { type: 'mousemove', cursorX: 186 }, ts, cfgMonFri) + expect(preview(left)).toEqual([D(15), D(15)]) + }) + + it('a calendar without working weekdays falls back to calendar days', () => { + const cfgNone: WorkingCalendar = { weekdayMask: 0, holidays: [] } + expect(preview(reduce(bodyState(D(18), D(20)), { type: 'mousemove', cursorX: 270 }, ts, cfgNone))).toEqual([ + D(23), + D(25) + ]) + expect(preview(reduce(leftState(D(18), D(19)), { type: 'mousemove', cursorX: 172 }, ts, cfgNone))[0]).toBe(D(16)) + expect(preview(reduce(rightState(D(18), D(21)), { type: 'mousemove', cursorX: 228 }, ts, cfgNone))[1]).toBe(D(23)) + expect( + preview(reduce(unscheduledState, { type: 'mousemove', cursorX: 200, canvasX: 5 * 14 }, ts2, cfgNone)) + ).toEqual([D(23), D(24)]) + }) + + it('a bar longer than MAX_WORKING_SPAN_DAYS or with a non-finite origin moves in calendar days', () => { + const farEnd = D(20) + 50_000 * 86_400_000 + expect(preview(reduce(bodyState(D(18), farEnd), { type: 'mousemove', cursorX: 270 }, ts, cfgMonFri))).toEqual([ + D(23), + farEnd + 5 * 86_400_000 + ]) + const [start, end] = preview(reduce(bodyState(D(18), Infinity), { type: 'mousemove', cursorX: 270 }, ts, cfgMonFri)) + expect(start).toBe(D(23)) + expect(end).toBeNaN() // same as without a calendar: snapToUtcMidnight(Infinity) + }) + + describe('a holiday blackout longer than the working-day search window', () => { + // Mon..Fri calendar where every day from Fri May 22 to Sun Oct 18 (150 + // days) is a holiday: from its middle no working day is within reach. + const DAY = 86_400_000 + const blackoutStart = D(22) + const blackoutEnd = D(22) + 150 * DAY // Mon Oct 19, first working day after + const blackout: WorkingCalendar = { + weekdayMask: 0b0011111, + holidays: Array.from({ length: 150 }, (_, i) => blackoutStart + i * DAY) + } + const inBlackout = (t: number | undefined): boolean => t !== undefined && t >= blackoutStart && t < blackoutEnd + + it('a body drag deep into the blackout moves in calendar days, as without a calendar', () => { + // +60 days → Fri Jul 17; the next working day (Oct 19) is 94 days away. + const move = { type: 'mousemove', cursorX: 200 + 60 * 14 } as const + const next = reduce(bodyState(D(18), D(20)), move, ts, blackout) + expect(preview(next)).toEqual([D(18) + 60 * DAY, D(20) + 60 * DAY]) + expect(preview(next)).toEqual(preview(reduce(bodyState(D(18), D(20)), move, ts))) + }) + + it('a body drag left from after the blackout into it moves in calendar days', () => { + // -10 days → Fri Oct 9; the previous working day (Thu May 21) is 141 days away. + const next = reduce(bodyState(blackoutEnd, blackoutEnd), { type: 'mousemove', cursorX: 60 }, ts, blackout) + expect(preview(next)).toEqual([blackoutEnd - 10 * DAY, blackoutEnd - 10 * DAY]) + }) + + it('a body drag whose working-day end would cross the blackout moves in calendar days', () => { + // Mon..Wed bar dragged one day right: Tue 19 is a working day, but a + // three-working-day end would have to cross the whole blackout. + const next = reduce(bodyState(D(18), D(20)), { type: 'mousemove', cursorX: 214 }, ts, blackout) + expect(preview(next)).toEqual([D(19), D(21)]) + }) + + it('a body drag near the edge of the blackout still snaps past it', () => { + // +120 days → Wed Sep 16; Mon Oct 19 is 33 days away, a one-day bar snaps there. + const next = reduce(bodyState(D(18), D(18)), { type: 'mousemove', cursorX: 200 + 120 * 14 }, ts, blackout) + expect(preview(next)).toEqual([blackoutEnd, blackoutEnd]) + }) + + it('resize handles deep in the blackout follow the pointer in calendar days', () => { + const left = reduce(leftState(D(18), blackoutEnd), { type: 'mousemove', cursorX: 200 + 60 * 14 }, ts, blackout) + expect(preview(left)[0]).toBe(D(18) + 60 * DAY) + const right = reduce(rightState(D(18), D(19)), { type: 'mousemove', cursorX: 200 + 80 * 14 }, ts, blackout) + expect(preview(right)[1]).toBe(D(19) + 80 * DAY) + }) + + it('an unscheduled drop deep in the blackout keeps the calendar-day drop', () => { + // canvasX 60 days after the scale origin (Mon May 18) → Fri Jul 17. + const next = reduce(unscheduledState, { type: 'mousemove', cursorX: 200, canvasX: 60 * 14 }, ts2, blackout) + expect(preview(next)).toEqual([D(18) + 60 * DAY, D(18) + 61 * DAY]) + }) + + it('never previews a non-working day as a snapped result', () => { + for (let days = -5; days <= 160; days++) { + const [s, e] = preview( + reduce(bodyState(D(18), D(20)), { type: 'mousemove', cursorX: 200 + days * 14 }, ts, blackout) + ) + const calendarDays = s === D(18) + days * DAY && e === D(20) + days * DAY + if (!calendarDays) { + expect(inBlackout(s)).toBe(false) + expect(inBlackout(e)).toBe(false) + } + } + }) + }) + + it('a non-finite cursor position terminates without a working-day loop', () => { + const next = reduce(bodyState(D(18), D(20)), { type: 'mousemove', cursorX: NaN }, ts, cfgMonFri) + expect(next.kind).toBe('dragging-body') + const [start, end] = preview(next) + expect(start).toBeNaN() + expect(end).toBeNaN() + }) + + it('a milestone target snaps like an issue in the reducer (the adapter decides)', () => { + const milestone: TestItem = { _id: 'ms-2', _class: 'tracker:class:Milestone', targetDate: D(20) } + const next = reduce( + { ...bodyState(D(18), D(20)), target: { kind: 'milestone', doc: milestone } } as unknown as DragState, + { type: 'mousemove', cursorX: 270 }, + ts, + cfgMonFri + ) + if (next.kind !== 'dragging-body') throw new Error('expected dragging-body') + expect(next.target.kind).toBe('milestone') + expect(preview(next)).toEqual([D(25), D(27)]) + }) +}) diff --git a/packages/gantt/src/drag-controller.ts b/packages/gantt/src/drag-controller.ts index 9f76c0d0ef8..55da4231167 100644 --- a/packages/gantt/src/drag-controller.ts +++ b/packages/gantt/src/drag-controller.ts @@ -3,9 +3,17 @@ // SPDX-License-Identifier: EPL-2.0 // -import type { DragEvent, DragState, DragTarget, GanttItem } from './types' +import type { DragEvent, DragState, DragTarget, GanttItem, WorkingCalendar } from './types' import { snapToUtcMidnight } from './time-scale' import type { TimeScale } from './time-scale' +import { + MAX_WORKING_SPAN_DAYS, + dueForSpan, + findWorkingDay, + isWorkingDay, + workingDaySpan, + workingDaysPerWeek +} from './working-days' /** * Pure reduction over drag state. Given the current state and an input event @@ -17,23 +25,35 @@ import type { TimeScale } from './time-scale' * and the captured drag target. `target.kind` (issue vs milestone) is * threaded through unchanged so commitDrag (in GanttView.svelte) can route * to the right update field. + * + * With a `calendar`, previews of single-bar body drags, resizes and drops + * land on working days (see `reduceFromActive`); a co-drag and any call + * without a calendar are unchanged. */ export function reduce ( state: DragState, event: DragEvent, - timeScale: TimeScale + timeScale: TimeScale, + calendar?: WorkingCalendar ): DragState { // The reducer is doc-agnostic: it only copies the `target` / `source` / // `hoveredBar` payloads through and reads their `_id`. Running the concrete // implementation and re-asserting the generic parameters is therefore sound — // whatever specific target/node the caller fed in is exactly what comes back. - return reduceImpl(state as unknown as DragState, event as unknown as DragEvent, timeScale) as unknown as DragState< - TTarget, - TNode - > + return reduceImpl( + state as unknown as DragState, + event as unknown as DragEvent, + timeScale, + calendar + ) as unknown as DragState } -function reduceImpl (state: DragState, event: DragEvent, timeScale: TimeScale): DragState { +function reduceImpl ( + state: DragState, + event: DragEvent, + timeScale: TimeScale, + calendar: WorkingCalendar | undefined +): DragState { switch (state.kind) { case 'idle': return reduceFromIdle(state, event) @@ -43,7 +63,7 @@ function reduceImpl (state: DragState, event: DragEvent, timeScale: TimeScale): case 'dragging-unscheduled': case 'resizing-left': case 'resizing-right': - return reduceFromActive(state, event, timeScale) + return reduceFromActive(state, event, timeScale, calendar) case 'connector-drawing': case 'connector-target-hover': return reduceFromConnector(state, event) @@ -171,11 +191,33 @@ function reduceFromHover (state: DragState & { kind: 'hover-bar' }, event: DragE return state } -function reduceFromActive (state: DragState, event: DragEvent, timeScale: TimeScale): DragState { +/** + * The calendar the active drag snaps to, or `undefined` for calendar-day + * behaviour. Degenerate cases fall back to calendar days: a calendar without + * any working weekday (nothing to snap to) and a bar whose origin is not a + * finite range of at most {@link MAX_WORKING_SPAN_DAYS} days (its working-day + * span could not be measured within the helpers' bounds). + */ +function snapCalendar (state: DragState, calendar: WorkingCalendar | undefined): WorkingCalendar | undefined { + if (calendar === undefined || workingDaysPerWeek(calendar) === 0) return undefined + if (state.kind === 'dragging-body') { + const spanDays = (state.originEnd - state.originStart) / 86_400_000 + if (!(spanDays >= 0 && spanDays <= MAX_WORKING_SPAN_DAYS)) return undefined + } + return calendar +} + +function reduceFromActive ( + state: DragState, + event: DragEvent, + timeScale: TimeScale, + requestedCalendar: WorkingCalendar | undefined +): DragState { if (event.type === 'mouseup' || event.type === 'cancel') { return { kind: 'idle' } } if (event.type !== 'mousemove') return state + const calendar = snapCalendar(state, requestedCalendar) if (state.kind === 'dragging-body') { const deltaPx = event.cursorX - state.cursorStartX @@ -184,6 +226,9 @@ function reduceFromActive (state: DragState, event: DragEvent, timeScale: TimeSc // shared min/max window — the hard-stop semantic. Snap // is computed against the clamped delta so the entire group lands on // identical UTC-midnight boundaries. + // The shared delta stays in calendar days even with a calendar: the + // hard-stop window is a millisecond window, so per-member working-day + // snapping could breach it (bulk snapping is a follow-up). if (state.coDrag !== undefined) { const clampedDeltaMs = Math.max(state.coDrag.minDeltaMs, Math.min(state.coDrag.maxDeltaMs, rawDeltaMs)) const previewStart = snapToUtcMidnight(state.originStart + clampedDeltaMs) @@ -199,9 +244,25 @@ function reduceFromActive (state: DragState, event: DragEvent, timeScale: TimeSc coDrag: { ...state.coDrag, anchorDeltaMs } } } + const candidate = snapToUtcMidnight(state.originStart + rawDeltaMs) + // Working-days mode: the start lands on the nearest working day in the + // drag direction and the bar keeps its length in working days, so it can + // never start or end on a weekend or holiday. A zero-delta move is left + // untouched so a click without movement commits nothing, and when no + // working day is reachable (a holiday blackout longer than the search + // window) the bar moves in calendar days rather than onto a non-working + // day presented as snapped. + if (calendar !== undefined && candidate !== snapToUtcMidnight(state.originStart)) { + const previewStart = findWorkingDay(candidate, rawDeltaMs < 0 ? -1 : 1, calendar) + if (previewStart !== undefined) { + const span = workingDaySpan(state.originStart, state.originEnd, calendar) + const previewEnd = dueForSpan(previewStart, span, calendar) + if (isWorkingDay(previewEnd, calendar)) return { ...state, previewStart, previewEnd } + } + } return { ...state, - previewStart: snapToUtcMidnight(state.originStart + rawDeltaMs), + previewStart: candidate, previewEnd: snapToUtcMidnight(state.originEnd + rawDeltaMs) } } @@ -210,25 +271,53 @@ function reduceFromActive (state: DragState, event: DragEvent, timeScale: TimeSc const deltaPx = event.cursorX - state.cursorStartX const deltaMs = (deltaPx / timeScale.pxPerDay) * 86_400_000 const candidate = snapToUtcMidnight(state.originStart + deltaMs) + // The start handle rounds up to the next working day (ceiling): with the + // pointer on a non-working day the start lands after it, so the bar is + // never extended onto a non-working day. No working day in reach: the + // handle follows the pointer in calendar days. + const snapped = + calendar === undefined || candidate === snapToUtcMidnight(state.originStart) + ? candidate + : (findWorkingDay(candidate, 1, calendar) ?? candidate) // Clamp so previewStart never crosses originEnd (would invert the bar). - return { ...state, previewStart: Math.min(candidate, state.originEnd) } + return { ...state, previewStart: Math.min(snapped, state.originEnd) } } if (state.kind === 'resizing-right') { const deltaPx = event.cursorX - state.cursorStartX const deltaMs = (deltaPx / timeScale.pxPerDay) * 86_400_000 const candidate = snapToUtcMidnight(state.originEnd + deltaMs) - return { ...state, previewEnd: Math.max(candidate, state.originStart) } + // The end handle rounds down to the previous working day (floor): with the + // pointer on a non-working day the end stays before it. No working day in + // reach: the handle follows the pointer in calendar days. + const snapped = + calendar === undefined || candidate === snapToUtcMidnight(state.originEnd) + ? candidate + : (findWorkingDay(candidate, -1, calendar) ?? candidate) + return { ...state, previewEnd: Math.max(snapped, state.originStart) } } if (state.kind === 'dragging-unscheduled') { // Only update the preview when the cursor is actually over the canvas. if (event.canvasX === undefined) return state - const newStart = snapToUtcMidnight(timeScale.fromX(event.canvasX)) + const dropped = snapToUtcMidnight(timeScale.fromX(event.canvasX)) + // With a calendar the drop starts on the next working day and the default + // two-day span counts working days; with no working day in reach it keeps + // the calendar-day drop. + let newStart = dropped + let newEnd = dropped + 86_400_000 + if (calendar !== undefined) { + const workingStart = findWorkingDay(dropped, 1, calendar) + const workingEnd = workingStart === undefined ? undefined : dueForSpan(workingStart, 2, calendar) + if (workingStart !== undefined && workingEnd !== undefined && isWorkingDay(workingEnd, calendar)) { + newStart = workingStart + newEnd = workingEnd + } + } return { ...state, previewStart: newStart, - previewEnd: newStart + 86_400_000, + previewEnd: newEnd, hasCanvasTarget: true } } From e80aab8ba279ac7872e2755c7a8725b4f1ca5383 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Tue, 6 Oct 2026 09:37:05 +0000 Subject: [PATCH 3/5] feat(tracker): snap Gantt drags to the project working days; move children and keyboard shifts by working days MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/gantt/GanttHelpPopup.svelte | 2 +- .../src/components/gantt/GanttView.svelte | 56 ++++++++++++++----- .../lib/__tests__/scheduler-cascade.test.ts | 52 ++++++++++++++++- .../src/components/gantt/lib/scheduler.ts | 33 ++++++++++- 4 files changed, 127 insertions(+), 16 deletions(-) diff --git a/plugins/tracker-resources/src/components/gantt/GanttHelpPopup.svelte b/plugins/tracker-resources/src/components/gantt/GanttHelpPopup.svelte index 02793706339..7fa9ab01fce 100644 --- a/plugins/tracker-resources/src/components/gantt/GanttHelpPopup.svelte +++ b/plugins/tracker-resources/src/components/gantt/GanttHelpPopup.svelte @@ -35,7 +35,7 @@ const rows: Row[] = [ { key: '?', label: 'Show this help' }, { key: '← →', label: 'Move selected issue ±1 day' }, - { key: 'Shift+← Shift+→', label: 'Move selected issue ±7 days' }, + { key: 'Shift+← Shift+→', label: 'Move selected issue ±1 week' }, { key: '+ / =', label: 'Zoom in' }, { key: '−', label: 'Zoom out' }, { key: 'T', label: 'Jump to today' }, diff --git a/plugins/tracker-resources/src/components/gantt/GanttView.svelte b/plugins/tracker-resources/src/components/gantt/GanttView.svelte index 8c6899dd15c..9be8e8bbd3c 100644 --- a/plugins/tracker-resources/src/components/gantt/GanttView.svelte +++ b/plugins/tracker-resources/src/components/gantt/GanttView.svelte @@ -33,7 +33,14 @@ import contact from '@hcengineering/contact' import { issuePriorities } from '../../types' import { connectedIssueIds } from './lib/dependency-router' - import { wouldCreateCycle, simulateCascade, addScheduleDays, descendantsWithDates } from './lib/scheduler' + import { + wouldCreateCycle, + simulateCascade, + descendantsWithDates, + shiftScheduleDays, + shiftWithPrimary, + keyboardWeekStep + } from './lib/scheduler' import { newCascadeToken, fsAnchor, @@ -1979,6 +1986,25 @@ return e.clientX - sidebarEdge + canvasViewportLeft } + /** + * Working-days snapping applies to single issue bars only: milestones keep + * plain calendar-day drags and a bulk co-drag keeps its shared raw delta + * (its hard-stop window is a millisecond window). + */ + function snapCalendarFor (s: DragState): WorkingCalendar | undefined { + if ( + s.kind !== 'dragging-body' && + s.kind !== 'dragging-unscheduled' && + s.kind !== 'resizing-left' && + s.kind !== 'resizing-right' + ) { + return undefined + } + if (s.target.kind !== 'issue') return undefined + if (s.kind === 'dragging-body' && s.coDrag !== undefined) return undefined + return effectiveCalendar + } + function handleCanvasPointerMove (e: MouseEvent): void { // once a confirmation popup is open the drag preview must // freeze at the position the user released the bar. Without this @@ -2013,7 +2039,7 @@ return // Don't also fire mousemove for bar drag } activeDrag.update((s) => - reduce(s, { type: 'mousemove', cursorX: e.clientX, canvasX: computeCanvasX(e) }, timeScale) + reduce(s, { type: 'mousemove', cursorX: e.clientX, canvasX: computeCanvasX(e) }, timeScale, snapCalendarFor(s)) ) } @@ -2862,7 +2888,6 @@ ) const isParent = allInSpace.some((i) => i.parents?.[0]?.parentId === parent._id) if (isParent) { - const delta = (state as any).previewStart - (state as any).originStart const primaryEdits: PrimaryEdit[] = [ { issue: parent, @@ -2873,8 +2898,13 @@ for (const child of descendantsWithDates(parent, allInSpace)) { primaryEdits.push({ issue: child, - newStart: (child.startDate as number) + delta, - newDue: (child.dueDate as number) + delta + newStart: shiftWithPrimary( + child.startDate as number, + state.originStart, + state.previewStart, + effectiveCalendar + ), + newDue: shiftWithPrimary(child.dueDate as number, state.originStart, state.previewStart, effectiveCalendar) }) } // Parent-drag fans out → primaryEdits.length > 1, so commitWithCascade @@ -3066,21 +3096,21 @@ ) // Stale-mutation guard after the await — before any edit is built. if (!mutationStillCurrent(i.space)) return - // All date arithmetic routes through addScheduleDays so the Phase-2 - // working-calendar swap stays a single integration point. + // All date arithmetic routes through shiftScheduleDays: calendar days in + // legacy mode, working days with a project calendar. const primaryEdits: PrimaryEdit[] = [ { issue: i, - newStart: addScheduleDays(i.startDate, days), - newDue: addScheduleDays(i.dueDate, days) + newStart: shiftScheduleDays(i.startDate, days, effectiveCalendar), + newDue: shiftScheduleDays(i.dueDate, days, effectiveCalendar) } ] // Include descendants (matches drag behaviour for parent shifts). for (const child of descendantsWithDates(i, allInSpace)) { primaryEdits.push({ issue: child, - newStart: addScheduleDays(child.startDate as number, days), - newDue: addScheduleDays(child.dueDate as number, days) + newStart: shiftScheduleDays(child.startDate as number, days, effectiveCalendar), + newDue: shiftScheduleDays(child.dueDate as number, days, effectiveCalendar) }) } // Keyboard shift has no Alt-modifier path and no legacy-confirm UX @@ -3188,13 +3218,13 @@ } if (e.key === 'ArrowRight') { if (isTextInputFocused()) return - void shiftFocused(e.shiftKey ? 7 : 1) + void shiftFocused(e.shiftKey ? keyboardWeekStep(effectiveCalendar) : 1) e.preventDefault() return } if (e.key === 'ArrowLeft') { if (isTextInputFocused()) return - void shiftFocused(e.shiftKey ? -7 : -1) + void shiftFocused(e.shiftKey ? -keyboardWeekStep(effectiveCalendar) : -1) e.preventDefault() return } diff --git a/plugins/tracker-resources/src/components/gantt/lib/__tests__/scheduler-cascade.test.ts b/plugins/tracker-resources/src/components/gantt/lib/__tests__/scheduler-cascade.test.ts index e582b3c6d24..fd7d64d835f 100644 --- a/plugins/tracker-resources/src/components/gantt/lib/__tests__/scheduler-cascade.test.ts +++ b/plugins/tracker-resources/src/components/gantt/lib/__tests__/scheduler-cascade.test.ts @@ -3,7 +3,14 @@ // SPDX-License-Identifier: EPL-2.0 // -import { detectCycle, addScheduleDays, simulateCascade } from '../scheduler' +import { + detectCycle, + addScheduleDays, + simulateCascade, + shiftScheduleDays, + shiftWithPrimary, + keyboardWeekStep +} from '../scheduler' import { isWorkingDay, fsAnchor, @@ -98,6 +105,49 @@ describe('addScheduleDays', () => { }) }) +describe('shiftScheduleDays / shiftWithPrimary / keyboardWeekStep', () => { + // May 2026: Mon 18 .. Wed 27. + const D = (day: number): number => Date.UTC(2026, 4, day) + const cfgMonFri = { weekdayMask: 0b0011111, holidays: [] } + + it('shiftScheduleDays counts working days with a calendar and calendar days without', () => { + expect(shiftScheduleDays(D(22), 1, cfgMonFri)).toBe(D(25)) + expect(shiftScheduleDays(D(22), 1, undefined)).toBe(D(23)) + expect(shiftScheduleDays(D(18), 7, undefined)).toBe(D(25)) + expect(shiftScheduleDays(D(18), 5, cfgMonFri)).toBe(D(25)) + expect(shiftScheduleDays(D(23), 0, cfgMonFri)).toBe(D(23)) + }) + + it('shiftWithPrimary moves a child by the primary move in working days with a calendar', () => { + expect(shiftWithPrimary(D(22), D(18), D(19), cfgMonFri)).toBe(D(25)) + expect(shiftWithPrimary(D(22), D(18), D(19), undefined)).toBe(D(23)) + }) + + it('shiftWithPrimary leaves the child unchanged for a zero move in both modes', () => { + expect(shiftWithPrimary(D(23), D(18), D(18), cfgMonFri)).toBe(D(23)) + expect(shiftWithPrimary(D(23), D(18), D(18), undefined)).toBe(D(23)) + }) + + it('shiftWithPrimary handles a move to the left', () => { + // Primary Mon 25 → Fri 22: one working day back, three calendar days back. + expect(shiftWithPrimary(D(27), D(25), D(22), cfgMonFri)).toBe(D(26)) + expect(shiftWithPrimary(D(27), D(25), D(22), undefined)).toBe(D(24)) + }) + + it('shiftWithPrimary falls back to the raw delta for a non-finite or huge move', () => { + const huge = 50_000 * 86_400_000 + expect(shiftWithPrimary(D(22), D(18), D(18) + huge, cfgMonFri)).toBe(D(22) + huge) + expect(shiftWithPrimary(D(22), D(18), Infinity, cfgMonFri)).toBe(Infinity) + expect(shiftWithPrimary(D(22), D(18), NaN, cfgMonFri)).toBeNaN() + }) + + it('keyboardWeekStep is seven calendar days without a calendar and the active weekdays with one', () => { + expect(keyboardWeekStep(undefined)).toBe(7) + expect(keyboardWeekStep(cfgMonFri)).toBe(5) + expect(keyboardWeekStep({ weekdayMask: 0b1111111, holidays: [] })).toBe(7) + }) +}) + describe('simulateCascade — FS basic', () => { it('Test 1: FS push — drag A 3d later → B shifts 3d later', () => { const A = issue('A', Date.UTC(2026, 4, 1), Date.UTC(2026, 4, 5)) diff --git a/plugins/tracker-resources/src/components/gantt/lib/scheduler.ts b/plugins/tracker-resources/src/components/gantt/lib/scheduler.ts index c75b54cd11b..aaeb099ef77 100644 --- a/plugins/tracker-resources/src/components/gantt/lib/scheduler.ts +++ b/plugins/tracker-resources/src/components/gantt/lib/scheduler.ts @@ -10,6 +10,8 @@ import { type WorkingCalendar, addWorkingDays, workingDayDelta, + workingDaysPerWeek, + MAX_WORKING_SPAN_DAYS, utcMidnight, fsAnchor, ssAnchor, @@ -33,6 +35,35 @@ export function addScheduleDays (t: number, days: number): number { return t + days * DAY_MS } +/** `days` as calendar days (legacy) or working days (calendar active). */ +export function shiftScheduleDays (t: number, days: number, cfg: WorkingCalendar | undefined): number { + return cfg === undefined ? addScheduleDays(t, days) : addWorkingDays(t, days, cfg) +} + +/** + * Moves `t` by the same amount as a primary bar whose start went from + * `originStart` to `previewStart`: the raw delta in legacy mode, the move + * measured in working days when a calendar is active, so a child bar lands on + * working days like its parent. A move that is not a finite distance of at + * most MAX_WORKING_SPAN_DAYS days falls back to the raw delta, which keeps the + * day-stepping working-day count bounded. + */ +export function shiftWithPrimary ( + t: number, + originStart: number, + previewStart: number, + cfg: WorkingCalendar | undefined +): number { + const delta = previewStart - originStart + if (cfg === undefined || !(Math.abs(delta) <= MAX_WORKING_SPAN_DAYS * DAY_MS)) return t + delta + return addWorkingDays(t, workingDayDelta(originStart, previewStart, cfg), cfg) +} + +/** Step of Shift+Arrow: seven calendar days (legacy) or the active weekdays of the mask. */ +export function keyboardWeekStep (cfg: WorkingCalendar | undefined): number { + return cfg === undefined ? 7 : workingDaysPerWeek(cfg) +} + /** * Return every descendant of `issue` that has both `startDate` and `dueDate` * concretely set. Children/grandchildren are walked recursively via the @@ -44,7 +75,7 @@ export function addScheduleDays (t: number, days: number): number { export function descendantsWithDates (issue: Issue, allIssues: Issue[]): Issue[] { const childrenByParent = new Map, Issue[]>() for (const i of allIssues) { - const parent = i.parents?.[0]?.parentId as Ref | undefined + const parent = i.parents?.[0]?.parentId if (parent === undefined) continue const bucket = childrenByParent.get(parent) if (bucket === undefined) { From 5d5723d9c2d1e574c446f5a5d1dd5d85a4e18e5d Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Tue, 6 Oct 2026 13:26:12 +0000 Subject: [PATCH 4/5] 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 --- .../src/__tests__/drag-controller.test.ts | 161 +++++++++++++++++- packages/gantt/src/drag-controller.ts | 69 +++++++- packages/gantt/src/types.ts | 36 +++- plugins/tracker-assets/lang/cs.json | 2 + plugins/tracker-assets/lang/de.json | 2 + plugins/tracker-assets/lang/en.json | 2 + plugins/tracker-assets/lang/es.json | 2 + plugins/tracker-assets/lang/fr.json | 2 + plugins/tracker-assets/lang/it.json | 2 + plugins/tracker-assets/lang/ja.json | 2 + plugins/tracker-assets/lang/ko.json | 2 + plugins/tracker-assets/lang/pl.json | 4 +- plugins/tracker-assets/lang/pt-br.json | 2 + plugins/tracker-assets/lang/pt.json | 2 + plugins/tracker-assets/lang/ru.json | 2 + plugins/tracker-assets/lang/tr.json | 2 + plugins/tracker-assets/lang/zh.json | 2 + .../components/gantt/GanttHelpPopup.svelte | 14 +- .../gantt/GanttResizeOverlay.svelte | 32 ++++ .../src/components/gantt/GanttView.svelte | 67 ++++++-- .../lib/__tests__/scheduler-cascade.test.ts | 56 +++++- plugins/tracker-resources/src/plugin.ts | 2 + 22 files changed, 449 insertions(+), 18 deletions(-) diff --git a/packages/gantt/src/__tests__/drag-controller.test.ts b/packages/gantt/src/__tests__/drag-controller.test.ts index f8e187cb3a4..5c37f8c30ef 100644 --- a/packages/gantt/src/__tests__/drag-controller.test.ts +++ b/packages/gantt/src/__tests__/drag-controller.test.ts @@ -3,7 +3,7 @@ // SPDX-License-Identifier: EPL-2.0 // -import { reduce } from '../drag-controller' +import { dragCalendar, modifierSyncMove, reduce } from '../drag-controller' import { createTimeScale, snapToUtcMidnight } from '../time-scale' import type { DragState, DragTarget, GanttItem, WorkingCalendar } from '../types' @@ -876,4 +876,163 @@ describe('drag-controller — working-days calendar', () => { expect(next.target.kind).toBe('milestone') expect(preview(next)).toEqual([D(25), D(27)]) }) + + describe('calendarDays override (Shift held during the drag)', () => { + const free = { type: 'mousemove', cursorX: 270, calendarDays: true } as const + const suspended = (s: DragState): boolean | undefined => ('snapSuspended' in s ? s.snapSuspended : undefined) + + it('a body drag onto a Saturday stays on Saturday and keeps the calendar length', () => { + const next = reduce(bodyState(D(18), D(20)), free, ts, cfgMonFri) + expect(preview(next)).toEqual([D(23), D(25)]) + expect(suspended(next)).toBe(true) + }) + + it('resize handles follow the pointer onto non-working days', () => { + const left = reduce( + leftState(D(18), D(19)), + { type: 'mousemove', cursorX: 172, calendarDays: true }, + ts, + cfgMonFri + ) + expect(preview(left)[0]).toBe(D(16)) + expect(suspended(left)).toBe(true) + const right = reduce( + rightState(D(18), D(21)), + { type: 'mousemove', cursorX: 228, calendarDays: true }, + ts, + cfgMonFri + ) + expect(preview(right)[1]).toBe(D(23)) + expect(suspended(right)).toBe(true) + }) + + it('an unscheduled drop on a Saturday starts on Saturday and lasts two calendar days', () => { + const next = reduce( + unscheduledState, + { type: 'mousemove', cursorX: 200, canvasX: 5 * 14, calendarDays: true }, + ts2, + cfgMonFri + ) + expect(preview(next)).toEqual([D(23), D(24)]) + expect(suspended(next)).toBe(true) + }) + + it('toggling the override mid-drag switches the preview at the same pointer position', () => { + const snapped = reduce(bodyState(D(18), D(20)), { type: 'mousemove', cursorX: 270 }, ts, cfgMonFri) + expect(preview(snapped)).toEqual([D(25), D(27)]) + expect(suspended(snapped)).toBeUndefined() + const pressed = reduce(snapped, free, ts, cfgMonFri) + expect(preview(pressed)).toEqual([D(23), D(25)]) + expect(suspended(pressed)).toBe(true) + const released = reduce(pressed, { type: 'mousemove', cursorX: 270, calendarDays: false }, ts, cfgMonFri) + expect(preview(released)).toEqual([D(25), D(27)]) + expect(suspended(released)).toBeUndefined() + expect('snapSuspended' in released).toBe(false) + }) + + it('toggling mid-resize switches between the snapped and the raw handle position', () => { + const snapped = reduce(rightState(D(18), D(21)), { type: 'mousemove', cursorX: 228 }, ts, cfgMonFri) + expect(preview(snapped)[1]).toBe(D(22)) + const pressed = reduce(snapped, { type: 'mousemove', cursorX: 228, calendarDays: true }, ts, cfgMonFri) + expect(preview(pressed)[1]).toBe(D(23)) + const released = reduce(pressed, { type: 'mousemove', cursorX: 228 }, ts, cfgMonFri) + expect(preview(released)[1]).toBe(D(22)) + }) + + it('without a calendar the override changes nothing and is not flagged', () => { + const plain = reduce(bodyState(D(18), D(20)), { type: 'mousemove', cursorX: 270 }, ts) + const next = reduce(bodyState(D(18), D(20)), free, ts) + expect(next).toEqual(plain) + expect('snapSuspended' in next).toBe(false) + const right = reduce(rightState(D(18), D(21)), { type: 'mousemove', cursorX: 228, calendarDays: true }, ts) + expect(preview(right)[1]).toBe(D(23)) + expect('snapSuspended' in right).toBe(false) + }) + + it('a co-drag is unaffected and not flagged (it never snaps)', () => { + const dragging: DragState = { + kind: 'dragging-body', + target: issueTarget, + originStart: D(18), + originEnd: D(20), + cursorStartX: 200, + previewStart: D(18), + previewEnd: D(20), + coDrag: { + anchorDeltaMs: 0, + members: [{ issueId: issue._id, originStart: D(18), originEnd: D(20) }], + minDeltaMs: -2 * 86_400_000, + maxDeltaMs: 5 * 86_400_000 + } + } + const next = reduce(dragging, { type: 'mousemove', cursorX: 284, calendarDays: true }, ts, cfgMonFri) + expect(preview(next)).toEqual([D(23), D(25)]) + expect('snapSuspended' in next).toBe(false) + }) + }) + + describe('dragCalendar', () => { + it('returns the calendar unless the active drag suspended snapping', () => { + expect(dragCalendar(bodyState(D(18), D(20)), cfgMonFri)).toBe(cfgMonFri) + const pressed = reduce( + bodyState(D(18), D(20)), + { type: 'mousemove', cursorX: 270, calendarDays: true }, + ts, + cfgMonFri + ) + expect(dragCalendar(pressed, cfgMonFri)).toBeUndefined() + expect(dragCalendar(pressed, undefined)).toBeUndefined() + expect(dragCalendar({ kind: 'idle' }, cfgMonFri)).toBe(cfgMonFri) + }) + }) + + describe('modifierSyncMove (Shift released outside the window)', () => { + const last = { cursorX: 270 } + + it('a blur or a release without Shift un-suspends the drag at the last position, so the commit snaps', () => { + const pressed = reduce( + bodyState(D(18), D(20)), + { type: 'mousemove', cursorX: 270, calendarDays: true }, + ts, + cfgMonFri + ) + expect(preview(pressed)).toEqual([D(23), D(25)]) + const move = modifierSyncMove(pressed, false, last) + expect(move).toEqual({ type: 'mousemove', cursorX: 270, canvasX: undefined, calendarDays: false }) + const synced = reduce(pressed, move as NonNullable, ts, cfgMonFri) + expect(preview(synced)).toEqual([D(25), D(27)]) + expect('snapSuspended' in synced).toBe(false) + expect(dragCalendar(synced, cfgMonFri)).toBe(cfgMonFri) + }) + + it('a release still holding Shift keeps the suspended preview (nothing to replay)', () => { + const pressed = reduce( + bodyState(D(18), D(20)), + { type: 'mousemove', cursorX: 270, calendarDays: true }, + ts, + cfgMonFri + ) + expect(modifierSyncMove(pressed, true, last)).toBeUndefined() + }) + + it('a Shift press on a snapped drag replays the move with the override', () => { + const snapped = reduce(bodyState(D(18), D(20)), { type: 'mousemove', cursorX: 270 }, ts, cfgMonFri) + expect(modifierSyncMove(snapped, false, last)).toBeUndefined() + const move = modifierSyncMove(snapped, true, last) + expect(move?.calendarDays).toBe(true) + expect(preview(reduce(snapped, move as NonNullable, ts, cfgMonFri))).toEqual([D(23), D(25)]) + }) + + it('keeps canvasX for an unscheduled drop and does nothing without a drag or a prior move', () => { + const pressed = reduce( + unscheduledState, + { type: 'mousemove', cursorX: 200, canvasX: 5 * 14, calendarDays: true }, + ts2, + cfgMonFri + ) + expect(modifierSyncMove(pressed, false, { cursorX: 200, canvasX: 5 * 14 })?.canvasX).toBe(5 * 14) + expect(modifierSyncMove(pressed, false, undefined)).toBeUndefined() + expect(modifierSyncMove({ kind: 'idle' }, false, last)).toBeUndefined() + }) + }) }) diff --git a/packages/gantt/src/drag-controller.ts b/packages/gantt/src/drag-controller.ts index 55da4231167..c9da77d6342 100644 --- a/packages/gantt/src/drag-controller.ts +++ b/packages/gantt/src/drag-controller.ts @@ -28,7 +28,9 @@ import { * * With a `calendar`, previews of single-bar body drags, resizes and drops * land on working days (see `reduceFromActive`); a co-drag and any call - * without a calendar are unchanged. + * without a calendar are unchanged. A `mousemove` with `calendarDays: true` + * suspends the snapping for that move (the per-drag override) and marks the + * state `snapSuspended`, so the next move without it snaps again. */ export function reduce ( state: DragState, @@ -200,6 +202,8 @@ function reduceFromHover (state: DragState & { kind: 'hover-bar' }, event: DragE */ function snapCalendar (state: DragState, calendar: WorkingCalendar | undefined): WorkingCalendar | undefined { if (calendar === undefined || workingDaysPerWeek(calendar) === 0) return undefined + // A co-drag moves by its shared raw delta and never snaps. + if (state.kind === 'dragging-body' && state.coDrag !== undefined) return undefined if (state.kind === 'dragging-body') { const spanDays = (state.originEnd - state.originStart) / 86_400_000 if (!(spanDays >= 0 && spanDays <= MAX_WORKING_SPAN_DAYS)) return undefined @@ -207,6 +211,48 @@ function snapCalendar (state: DragState, calendar: WorkingCalendar | undefined): return calendar } +/** + * The calendar a commit should use for date arithmetic that belongs to the + * drag itself (e.g. shifting a dragged parent's children): `undefined` when + * the drag suspended working-day snapping, so the whole gesture stays in + * calendar days, otherwise `calendar` unchanged. + */ +export function dragCalendar ( + state: DragState, + calendar: WorkingCalendar | undefined +): WorkingCalendar | undefined { + return 'snapSuspended' in state && state.snapSuspended === true ? undefined : calendar +} + +/** + * The `mousemove` that re-syncs a running bar drag, resize or drop with the + * current state of the calendar-days modifier, replayed at the last pointer + * position, or `undefined` when there is nothing to re-sync (no such drag, + * no move yet, or the state already matches). Used on key presses, on + * pointer release (from the event's own modifier flag) and on window blur + * (modifier treated as released), so a Shift released outside the window + * cannot leave the drag suspended and commit calendar-day dates. + */ +export function modifierSyncMove ( + state: DragState, + calendarDays: boolean, + lastMove: { cursorX: number, canvasX?: number } | undefined +): (DragEvent & { type: 'mousemove' }) | undefined { + if (lastMove === undefined) return undefined + if ( + state.kind !== 'dragging-body' && + state.kind !== 'dragging-unscheduled' && + state.kind !== 'resizing-left' && + state.kind !== 'resizing-right' + ) { + return undefined + } + // Skip when already in sync. A press on an unflagged state is still + // replayed: the reducer flags it only when a calendar applies. + if ((state.snapSuspended === true) === calendarDays) return undefined + return { type: 'mousemove', cursorX: lastMove.cursorX, canvasX: lastMove.canvasX, calendarDays } +} + function reduceFromActive ( state: DragState, event: DragEvent, @@ -217,8 +263,27 @@ function reduceFromActive ( return { kind: 'idle' } } if (event.type !== 'mousemove') return state - const calendar = snapCalendar(state, requestedCalendar) + const snapping = snapCalendar(state, requestedCalendar) + // The override only counts (and is only flagged) when there is snapping to + // suspend: without a calendar, for a co-drag or a degenerate calendar the + // move is calendar-day anyway. + const suspended = event.calendarDays === true && snapping !== undefined + const next = previewFromMove(state, event, timeScale, suspended ? undefined : snapping) + if (suspended) return { ...next, snapSuspended: true } as unknown as DragState + if ('snapSuspended' in next) { + const { snapSuspended, ...rest } = next + void snapSuspended + return rest as DragState + } + return next +} +function previewFromMove ( + state: DragState, + event: DragEvent & { type: 'mousemove' }, + timeScale: TimeScale, + calendar: WorkingCalendar | undefined +): DragState { if (state.kind === 'dragging-body') { const deltaPx = event.cursorX - state.cursorStartX const rawDeltaMs = (deltaPx / timeScale.pxPerDay) * 86_400_000 diff --git a/packages/gantt/src/types.ts b/packages/gantt/src/types.ts index fc2dff173b2..9077823d34f 100644 --- a/packages/gantt/src/types.ts +++ b/packages/gantt/src/types.ts @@ -129,6 +129,12 @@ export type DragState @@ -57,7 +65,9 @@ {#each rows as r (r.key)}
{r.key} - {r.label} + + {#if r.intl !== undefined}
{/each} diff --git a/plugins/tracker-resources/src/components/gantt/GanttResizeOverlay.svelte b/plugins/tracker-resources/src/components/gantt/GanttResizeOverlay.svelte index 8232dcbd949..07ae8d0dea0 100644 --- a/plugins/tracker-resources/src/components/gantt/GanttResizeOverlay.svelte +++ b/plugins/tracker-resources/src/components/gantt/GanttResizeOverlay.svelte @@ -94,6 +94,16 @@ tip = tipParams === null ? null : await translate(tracker.string.GanttResizingTooltip, tipParams, $themeStore.language) })() + + // Shown under the date pill while the user holds Shift to suspend + // working-day snapping for this drag (the reducer only sets the flag when a + // calendar would otherwise apply). + $: suspended = 'snapSuspended' in state && state.snapSuspended === true + let suspendedHint = '' + $: void (async () => { + suspendedHint = suspended ? await translate(tracker.string.GanttDragCalendarDays, {}, $themeStore.language) : '' + })() + $: hintWidth = Math.max(88, suspendedHint.length * 6.5 + 16) {#if state.kind !== 'idle' && state.kind !== 'hover-bar' && geom !== null} @@ -134,6 +144,24 @@ {/if} +{#if suspended && suspendedHint !== '' && pd !== null && gx !== null} + + + {suspendedHint} + +{/if} + {#if tip !== null && gx !== null} + reduce(s, { type: 'mousemove', cursorX, canvasX, calendarDays }, timeScale, snapCalendarFor(s)) + ) + } + + /** + * Re-sync the running drag with the modifier state at the last pointer + * position. Called on Shift key changes, on pointer release (from the + * event's `shiftKey`) and on window blur (as released): a Shift released + * outside the window must not leave the drag committing calendar days. + */ + function syncDragModifier (calendarDays: boolean): void { + if (confirmGate.isConfirming()) return + const move = modifierSyncMove($activeDrag, calendarDays, lastDragMove) + if (move === undefined) return + dispatchDragMove(move.cursorX, move.canvasX, calendarDays) + } + + function onDragModifierKey (e: KeyboardEvent): void { + if (e.key !== 'Shift') return + syncDragModifier(e.shiftKey) + } + + function onDragWindowBlur (): void { + syncDragModifier(false) + } + function handleCanvasPointerMove (e: MouseEvent): void { // once a confirmation popup is open the drag preview must // freeze at the position the user released the bar. Without this @@ -2038,9 +2079,7 @@ ) return // Don't also fire mousemove for bar drag } - activeDrag.update((s) => - reduce(s, { type: 'mousemove', cursorX: e.clientX, canvasX: computeCanvasX(e) }, timeScale, snapCalendarFor(s)) - ) + dispatchDragMove(e.clientX, computeCanvasX(e), e.shiftKey) } async function handleCanvasPointerUp (e?: PointerEvent | MouseEvent): Promise { @@ -2051,6 +2090,9 @@ // (double-popup bug). The popup's own resolve handler is the single // exit point that releases the gate and decides commit/cancel. if (confirmGate.isConfirming()) return + // Commit with the modifier state of the release itself, not of the last + // key event the window saw (Shift may have been released elsewhere). + if (e !== undefined) syncDragModifier(e.shiftKey) const state = $activeDrag if (state.kind === 'connector-drawing') { activeDrag.set({ kind: 'idle' }) @@ -2895,16 +2937,14 @@ newDue: (state as any).previewEnd } ] + // Children move like the parent: by working days, or by calendar + // days when this drag suspended the snapping (Shift held). + const childCalendar = dragCalendar(state, effectiveCalendar) for (const child of descendantsWithDates(parent, allInSpace)) { primaryEdits.push({ issue: child, - newStart: shiftWithPrimary( - child.startDate as number, - state.originStart, - state.previewStart, - effectiveCalendar - ), - newDue: shiftWithPrimary(child.dueDate as number, state.originStart, state.previewStart, effectiveCalendar) + newStart: shiftWithPrimary(child.startDate as number, state.originStart, state.previewStart, childCalendar), + newDue: shiftWithPrimary(child.dueDate as number, state.originStart, state.previewStart, childCalendar) }) } // Parent-drag fans out → primaryEdits.length > 1, so commitWithCascade @@ -2969,6 +3009,9 @@ window.addEventListener('pointercancel', onWindowPointerUp) window.addEventListener('mousemove', handleCanvasPointerMove) window.addEventListener('mouseup', onWindowPointerUp) + window.addEventListener('keydown', onDragModifierKey) + window.addEventListener('keyup', onDragModifierKey) + window.addEventListener('blur', onDragWindowBlur) } function detachWindowDragListeners (): void { @@ -2977,6 +3020,10 @@ window.removeEventListener('pointercancel', onWindowPointerUp) window.removeEventListener('mousemove', handleCanvasPointerMove) window.removeEventListener('mouseup', onWindowPointerUp) + window.removeEventListener('keydown', onDragModifierKey) + window.removeEventListener('keyup', onDragModifierKey) + window.removeEventListener('blur', onDragWindowBlur) + lastDragMove = undefined } // Attach/detach window-level pointer listeners only while a drag is active. diff --git a/plugins/tracker-resources/src/components/gantt/lib/__tests__/scheduler-cascade.test.ts b/plugins/tracker-resources/src/components/gantt/lib/__tests__/scheduler-cascade.test.ts index fd7d64d835f..e77a86971fa 100644 --- a/plugins/tracker-resources/src/components/gantt/lib/__tests__/scheduler-cascade.test.ts +++ b/plugins/tracker-resources/src/components/gantt/lib/__tests__/scheduler-cascade.test.ts @@ -12,6 +12,9 @@ import { keyboardWeekStep } from '../scheduler' import { + createTimeScale, + dragCalendar, + reduce, isWorkingDay, fsAnchor, fsReverseAnchor, @@ -24,7 +27,7 @@ import { } from '@hcengineering/gantt' import type { Issue, IssueRelation } from '@hcengineering/tracker' import type { Ref } from '@hcengineering/core' -import type { PrimaryEdit } from '../types' +import type { DragState, DragTarget, PrimaryEdit } from '../types' function issue (id: string, start?: number, due?: number): Issue { return { @@ -123,6 +126,57 @@ describe('shiftScheduleDays / shiftWithPrimary / keyboardWeekStep', () => { expect(shiftWithPrimary(D(22), D(18), D(19), undefined)).toBe(D(23)) }) + describe('parent drag with the calendar-days override (Shift held)', () => { + // Parent Mon 18 – Wed 20 with child Tue 19 – Wed 20, dragged +5 days so + // the pointer is on Saturday 23. GanttView shifts the children with + // shiftWithPrimary(…, dragCalendar(state, calendar)). + const parent = issue('P', D(18), D(20)) + const ts = createTimeScale('week', D(18)) + const start: DragState = { + kind: 'dragging-body', + target: { kind: 'issue', doc: parent }, + originStart: D(18), + originEnd: D(20), + cursorStartX: 200, + previewStart: D(18), + previewEnd: D(20) + } + const move = (state: DragState, calendarDays: boolean | undefined, cfg: typeof cfgMonFri | undefined): DragState => + reduce(state, { type: 'mousemove', cursorX: 270, calendarDays }, ts, cfg) + const children = (state: DragState, cfg: typeof cfgMonFri | undefined): number[] => { + if (state.kind !== 'dragging-body') throw new Error('expected dragging-body') + const childCfg = dragCalendar(state, cfg) + return [D(19), D(20)].map((t) => shiftWithPrimary(t, state.originStart, state.previewStart, childCfg)) + } + + it('without Shift the parent snaps to Monday and the child moves by working days', () => { + const next = move(start, undefined, cfgMonFri) + if (next.kind !== 'dragging-body') throw new Error('expected dragging-body') + expect([next.previewStart, next.previewEnd]).toEqual([D(25), D(27)]) + expect(children(next, cfgMonFri)).toEqual([D(26), D(27)]) + }) + + it('with Shift parent and child move by calendar days onto the weekend', () => { + const next = move(start, true, cfgMonFri) + if (next.kind !== 'dragging-body') throw new Error('expected dragging-body') + expect([next.previewStart, next.previewEnd]).toEqual([D(23), D(25)]) + expect(children(next, cfgMonFri)).toEqual([D(24), D(25)]) + }) + + it('releasing Shift before the drop restores the working-day result', () => { + const pressed = move(start, true, cfgMonFri) + const released = move(pressed, false, cfgMonFri) + expect(children(released, cfgMonFri)).toEqual([D(26), D(27)]) + }) + + it('in legacy mode Shift changes nothing', () => { + const plain = move(start, undefined, undefined) + const pressed = move(start, true, undefined) + expect(pressed).toEqual(plain) + expect(children(pressed, undefined)).toEqual([D(24), D(25)]) + }) + }) + it('shiftWithPrimary leaves the child unchanged for a zero move in both modes', () => { expect(shiftWithPrimary(D(23), D(18), D(18), cfgMonFri)).toBe(D(23)) expect(shiftWithPrimary(D(23), D(18), D(18), undefined)).toBe(D(23)) diff --git a/plugins/tracker-resources/src/plugin.ts b/plugins/tracker-resources/src/plugin.ts index 665a87f98e1..9770cedffea 100644 --- a/plugins/tracker-resources/src/plugin.ts +++ b/plugins/tracker-resources/src/plugin.ts @@ -407,6 +407,8 @@ export default mergeIds(trackerId, tracker, { // Export + keyboard help GanttHelpTitle: '' as IntlString, GanttHelpEsc: '' as IntlString, + GanttHelpCalendarDaysDrag: '' as IntlString, + GanttDragCalendarDays: '' as IntlString, GanttExport: '' as IntlString, GanttExportFailed: '' as IntlString, // Fullscreen + PNG/PDF export buttons From 2ce5d7d99b53293aab4051041f4cb2a84dd92340 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Tue, 6 Oct 2026 15:45:29 +0000 Subject: [PATCH 5/5] 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 --- .../gantt/src/__tests__/working-days.test.ts | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/gantt/src/__tests__/working-days.test.ts b/packages/gantt/src/__tests__/working-days.test.ts index a307059687f..6b2560fac2b 100644 --- a/packages/gantt/src/__tests__/working-days.test.ts +++ b/packages/gantt/src/__tests__/working-days.test.ts @@ -418,14 +418,22 @@ describe('working-day helpers — degenerate input stays bounded', () => { expect(findWorkingDay(WED + 3 * 3_600_000, -1, cfgMonFri)).toBe(WED) }) - it('addWorkingDays caps a huge finite step count and finishes quickly', () => { - const t0 = Date.now() + // No wall-clock assertions: every step of the walk moves the cursor by one + // calendar day, so the distance of the result from the input is an exact, + // runner-independent count of the loop iterations. + it('addWorkingDays caps a huge finite step count at MAX_WORKING_SPAN_DAYS', () => { expect(addWorkingDays(MON, Number.MAX_SAFE_INTEGER, cfgAllDays)).toBe(MON + MAX_WORKING_SPAN_DAYS * DAY_MS) expect(addWorkingDays(MON, -Number.MAX_SAFE_INTEGER, cfgAllDays)).toBe(MON - MAX_WORKING_SPAN_DAYS * DAY_MS) expect(addWorkingDays(MON, Number.MAX_VALUE, cfgMonFri)).toBe(addWorkingDays(MON, MAX_WORKING_SPAN_DAYS, cfgMonFri)) - expect(Number.isFinite(addWorkingDays(MON, 1e15, cfgEmpty))).toBe(true) - expect(Number.isFinite(addWorkingDays(MON, -1e15, allHolidays))).toBe(true) - expect(Date.now() - t0).toBeLessThan(2000) + }) + + it('addWorkingDays stops after |n| × 7 + 60 calendar days without working weekdays', () => { + expect(addWorkingDays(MON, 3, cfgEmpty)).toBe(MON + (3 * 7 + 60) * DAY_MS) + expect(addWorkingDays(MON, -3, cfgEmpty)).toBe(MON - (3 * 7 + 60) * DAY_MS) + // A huge step count is capped first, so the walk is bounded by the cap. + expect(addWorkingDays(MON, 1e15, cfgEmpty)).toBe(MON + (MAX_WORKING_SPAN_DAYS * 7 + 60) * DAY_MS) + // A holiday blackout is walked through and the remaining steps still count. + expect(addWorkingDays(MON + 69 * DAY_MS, -10, allHolidays)).toBe(MON - 10 * DAY_MS) }) it('addWorkingDays treats a non-finite step count as zero', () => { @@ -455,8 +463,8 @@ describe('working-day helpers — degenerate input stays bounded', () => { it('dueForSpan / startForSpan clamp a huge span and terminate without working weekdays', () => { expect(dueForSpan(MON, 1e12, cfgAllDays)).toBe(MON + (MAX_WORKING_SPAN_DAYS - 1) * DAY_MS) expect(startForSpan(MON, 1e12, cfgAllDays)).toBe(MON - (MAX_WORKING_SPAN_DAYS - 1) * DAY_MS) - expect(Number.isFinite(dueForSpan(MON, 1e12, cfgEmpty))).toBe(true) - expect(Number.isFinite(startForSpan(MON, 1e12, cfgEmpty))).toBe(true) + expect(dueForSpan(MON, 1e12, cfgEmpty)).toBe(MON + ((MAX_WORKING_SPAN_DAYS - 1) * 7 + 60) * DAY_MS) + expect(startForSpan(MON, 3, cfgEmpty)).toBe(MON - (2 * 7 + 60) * DAY_MS) }) it('dueForSpan / startForSpan return a non-finite anchor unchanged', () => {