diff --git a/packages/gantt/src/__tests__/drag-controller.test.ts b/packages/gantt/src/__tests__/drag-controller.test.ts index 8713bfab79b..5c37f8c30ef 100644 --- a/packages/gantt/src/__tests__/drag-controller.test.ts +++ b/packages/gantt/src/__tests__/drag-controller.test.ts @@ -3,9 +3,9 @@ // 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 } 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,423 @@ 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)]) + }) + + 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/__tests__/working-days.test.ts b/packages/gantt/src/__tests__/working-days.test.ts index c2d2ecf2a61..6b2560fac2b 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,170 @@ 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) + }) + + // 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)) + }) + + 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', () => { + 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(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', () => { + expect(dueForSpan(NaN, 3, cfgMonFri)).toBeNaN() + expect(startForSpan(Infinity, 3, cfgMonFri)).toBe(Infinity) + }) +}) diff --git a/packages/gantt/src/drag-controller.ts b/packages/gantt/src/drag-controller.ts index 9f76c0d0ef8..c9da77d6342 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,37 @@ 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. 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, 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 +65,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,12 +193,97 @@ 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 + // 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 + } + 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, + timeScale: TimeScale, + requestedCalendar: WorkingCalendar | undefined +): DragState { if (event.type === 'mouseup' || event.type === 'cancel') { return { kind: 'idle' } } if (event.type !== 'mousemove') return state + 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 @@ -184,6 +291,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 +309,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 +336,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 } } 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 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 diff --git a/plugins/tracker-assets/lang/cs.json b/plugins/tracker-assets/lang/cs.json index 4d785162d80..90dd1af4446 100644 --- a/plugins/tracker-assets/lang/cs.json +++ b/plugins/tracker-assets/lang/cs.json @@ -303,6 +303,8 @@ "GanttExpand": "Expand", "GanttCollapse": "Collapse", "GanttCornerNoOpInSwimlane": "No effect in group-by mode", + "GanttHelpCalendarDaysDrag": "Podržením při přetahování ignorovat pracovní dny (kalendářní dny)", + "GanttDragCalendarDays": "Kalendářní dny", "ModeSelectorDisabledByFilter": "Stav je řízen aktivním filtrem. Chcete-li použít tuto zkratku, odstraňte filtr stavu.", "ShowQuickModeSelector": "Rychlý filtr: Vše / Aktivní / Návrhy", "SearchScopeLabel": "Hledat v…", diff --git a/plugins/tracker-assets/lang/de.json b/plugins/tracker-assets/lang/de.json index 4886ccce28d..1b0678b96b9 100644 --- a/plugins/tracker-assets/lang/de.json +++ b/plugins/tracker-assets/lang/de.json @@ -443,6 +443,8 @@ "SlackColumn": "Puffer-Spalte", "GanttHelpTitle": "Tastenkürzel", "GanttHelpEsc": "Esc oder ? zum Schließen drücken", + "GanttHelpCalendarDaysDrag": "Beim Ziehen gedrückt halten, um Arbeitstage zu ignorieren (Kalendertage)", + "GanttDragCalendarDays": "Kalendertage", "GanttExport": "Als PNG exportieren", "GanttExportFailed": "Export fehlgeschlagen", "GanttSidebarColumnsExtended": "Erweiterte Sidebar-Spalten", diff --git a/plugins/tracker-assets/lang/en.json b/plugins/tracker-assets/lang/en.json index d9dc6a07ca5..b54f1152f0f 100644 --- a/plugins/tracker-assets/lang/en.json +++ b/plugins/tracker-assets/lang/en.json @@ -443,6 +443,8 @@ "CriticalPathBadge": "CP", "GanttHelpTitle": "Keyboard shortcuts", "GanttHelpEsc": "Press Esc or ? to close", + "GanttHelpCalendarDaysDrag": "Hold while dragging to ignore working days (calendar days)", + "GanttDragCalendarDays": "Calendar days", "GanttExport": "Export to PNG", "GanttExportFailed": "Export failed", "GanttSidebarColumnsExtended": "Extended sidebar columns", diff --git a/plugins/tracker-assets/lang/es.json b/plugins/tracker-assets/lang/es.json index 1ff5a8a77bc..77cc31b392f 100644 --- a/plugins/tracker-assets/lang/es.json +++ b/plugins/tracker-assets/lang/es.json @@ -296,6 +296,8 @@ "GanttExpand": "Expand", "GanttCollapse": "Collapse", "GanttCornerNoOpInSwimlane": "No effect in group-by mode", + "GanttHelpCalendarDaysDrag": "Mantener pulsado al arrastrar para ignorar los días laborables (días naturales)", + "GanttDragCalendarDays": "Días naturales", "ModeSelectorDisabledByFilter": "El estado está controlado por un filtro activo. Elimina el filtro de estado para usar este atajo.", "ShowQuickModeSelector": "Filtro rápido: Todos / Activos / Atrasados", "SearchScopeLabel": "Buscar en…", diff --git a/plugins/tracker-assets/lang/fr.json b/plugins/tracker-assets/lang/fr.json index df9e6094c23..993900aceef 100644 --- a/plugins/tracker-assets/lang/fr.json +++ b/plugins/tracker-assets/lang/fr.json @@ -296,6 +296,8 @@ "GanttExpand": "Expand", "GanttCollapse": "Collapse", "GanttCornerNoOpInSwimlane": "No effect in group-by mode", + "GanttHelpCalendarDaysDrag": "Maintenir pendant le glissement pour ignorer les jours ouvrés (jours calendaires)", + "GanttDragCalendarDays": "Jours calendaires", "ModeSelectorDisabledByFilter": "Le statut est contrôlé par un filtre actif. Supprimez le filtre de statut pour utiliser ce raccourci.", "ShowQuickModeSelector": "Filtre rapide : Tous / Actif / Backlog", "SearchScopeLabel": "Rechercher dans…", diff --git a/plugins/tracker-assets/lang/it.json b/plugins/tracker-assets/lang/it.json index 318f9326f71..7491e28c44d 100644 --- a/plugins/tracker-assets/lang/it.json +++ b/plugins/tracker-assets/lang/it.json @@ -296,6 +296,8 @@ "GanttExpand": "Expand", "GanttCollapse": "Collapse", "GanttCornerNoOpInSwimlane": "No effect in group-by mode", + "GanttHelpCalendarDaysDrag": "Tieni premuto durante il trascinamento per ignorare i giorni lavorativi (giorni di calendario)", + "GanttDragCalendarDays": "Giorni di calendario", "ModeSelectorDisabledByFilter": "Lo stato è controllato da un filtro attivo. Rimuovi il filtro di stato per usare questa scorciatoia.", "ShowQuickModeSelector": "Filtro rapido: Tutti / Attivi / Backlog", "SearchScopeLabel": "Cerca in…", diff --git a/plugins/tracker-assets/lang/ja.json b/plugins/tracker-assets/lang/ja.json index d060f6f83ea..d7a5d9b4970 100644 --- a/plugins/tracker-assets/lang/ja.json +++ b/plugins/tracker-assets/lang/ja.json @@ -296,6 +296,8 @@ "GanttExpand": "Expand", "GanttCollapse": "Collapse", "GanttCornerNoOpInSwimlane": "No effect in group-by mode", + "GanttHelpCalendarDaysDrag": "ドラッグ中に押し続けると稼働日を無視(暦日)", + "GanttDragCalendarDays": "暦日", "ModeSelectorDisabledByFilter": "ステータスはアクティブなフィルターで制御されています。このショートカットを使用するにはステータスフィルターを解除してください。", "ShowQuickModeSelector": "クイックフィルター:すべて / アクティブ / バックログ", "SearchScopeLabel": "検索対象…", diff --git a/plugins/tracker-assets/lang/ko.json b/plugins/tracker-assets/lang/ko.json index 0d3bba943ee..1f494b440c4 100644 --- a/plugins/tracker-assets/lang/ko.json +++ b/plugins/tracker-assets/lang/ko.json @@ -296,6 +296,8 @@ "GanttExpand": "Expand", "GanttCollapse": "Collapse", "GanttCornerNoOpInSwimlane": "No effect in group-by mode", + "GanttHelpCalendarDaysDrag": "드래그 중 누르고 있으면 근무일 무시 (달력일)", + "GanttDragCalendarDays": "달력일", "ModeSelectorDisabledByFilter": "상태가 활성 필터로 제어되고 있습니다. 이 단축키를 사용하려면 상태 필터를 제거하세요.", "ShowQuickModeSelector": "빠른 필터: 전체 / 활성 / 백로그", "SearchScopeLabel": "검색 범위…", diff --git a/plugins/tracker-assets/lang/pl.json b/plugins/tracker-assets/lang/pl.json index fa2e8555275..9cf1984152d 100644 --- a/plugins/tracker-assets/lang/pl.json +++ b/plugins/tracker-assets/lang/pl.json @@ -290,7 +290,9 @@ "UnsetParentIssue": "Wyczyść zagadnienie nadrzędne", "ForbidCreateProjectPermission": "Zakaż tworzenia projektów", "ForbidCreateProjectPermissionDescription": "Zakaż użytkownikom tworzenia nowych projektów.", - "AllowCreatingIssues": "Zezwól na tworzenie zadań" + "AllowCreatingIssues": "Zezwól na tworzenie zadań", + "GanttHelpCalendarDaysDrag": "Przytrzymaj podczas przeciągania, aby ignorować dni robocze (dni kalendarzowe)", + "GanttDragCalendarDays": "Dni kalendarzowe" }, "status": {} } diff --git a/plugins/tracker-assets/lang/pt-br.json b/plugins/tracker-assets/lang/pt-br.json index fb3ea902261..e335539d269 100644 --- a/plugins/tracker-assets/lang/pt-br.json +++ b/plugins/tracker-assets/lang/pt-br.json @@ -296,6 +296,8 @@ "GanttExpand": "Expand", "GanttCollapse": "Collapse", "GanttCornerNoOpInSwimlane": "No effect in group-by mode", + "GanttHelpCalendarDaysDrag": "Segure ao arrastar para ignorar os dias úteis (dias corridos)", + "GanttDragCalendarDays": "Dias corridos", "ModeSelectorDisabledByFilter": "O estado é controlado por um filtro ativo. Remova o filtro de estado para usar este atalho.", "ShowQuickModeSelector": "Filtro rápido: Todos / Ativos / Backlog", "SearchScopeLabel": "Procurar em…", diff --git a/plugins/tracker-assets/lang/pt.json b/plugins/tracker-assets/lang/pt.json index 4e5ffcb65a1..de2826546d4 100644 --- a/plugins/tracker-assets/lang/pt.json +++ b/plugins/tracker-assets/lang/pt.json @@ -296,6 +296,8 @@ "GanttExpand": "Expand", "GanttCollapse": "Collapse", "GanttCornerNoOpInSwimlane": "No effect in group-by mode", + "GanttHelpCalendarDaysDrag": "Mantenha premido ao arrastar para ignorar os dias úteis (dias de calendário)", + "GanttDragCalendarDays": "Dias de calendário", "ModeSelectorDisabledByFilter": "O estado é controlado por um filtro ativo. Remova o filtro de estado para usar este atalho.", "ShowQuickModeSelector": "Filtro rápido: Todos / Ativos / Atraso", "SearchScopeLabel": "Procurar em…", diff --git a/plugins/tracker-assets/lang/ru.json b/plugins/tracker-assets/lang/ru.json index a3e3bacfa6d..ac28e2d4186 100644 --- a/plugins/tracker-assets/lang/ru.json +++ b/plugins/tracker-assets/lang/ru.json @@ -443,6 +443,8 @@ "CriticalPathBadge": "КП", "GanttHelpTitle": "Сочетания клавиш", "GanttHelpEsc": "Нажмите Esc или ?, чтобы закрыть", + "GanttHelpCalendarDaysDrag": "Удерживайте при перетаскивании, чтобы игнорировать рабочие дни (календарные дни)", + "GanttDragCalendarDays": "Календарные дни", "GanttExport": "Экспорт в PNG", "GanttExportFailed": "Не удалось выполнить экспорт", "GanttSidebarColumnsExtended": "Расширенные столбцы боковой панели", diff --git a/plugins/tracker-assets/lang/tr.json b/plugins/tracker-assets/lang/tr.json index 34aa6ad5d5d..3b5786f8cf0 100644 --- a/plugins/tracker-assets/lang/tr.json +++ b/plugins/tracker-assets/lang/tr.json @@ -426,6 +426,8 @@ "CriticalPathBadge": "KY", "GanttHelpTitle": "Klavye kısayolları", "GanttHelpEsc": "Kapatmak için Esc veya ? tuşuna basın", + "GanttHelpCalendarDaysDrag": "Sürüklerken basılı tutun: iş günlerini yok say (takvim günleri)", + "GanttDragCalendarDays": "Takvim günleri", "GanttExport": "PNG olarak dışa aktar", "GanttExportFailed": "Dışa aktarma başarısız", "GanttSidebarColumnsExtended": "Genişletilmiş kenar çubuğu sütunları", diff --git a/plugins/tracker-assets/lang/zh.json b/plugins/tracker-assets/lang/zh.json index 09b80894b23..c57a8c98513 100644 --- a/plugins/tracker-assets/lang/zh.json +++ b/plugins/tracker-assets/lang/zh.json @@ -313,6 +313,8 @@ "GanttExpand": "Expand", "GanttCollapse": "Collapse", "GanttCornerNoOpInSwimlane": "No effect in group-by mode", + "GanttHelpCalendarDaysDrag": "拖动时按住可忽略工作日(按日历日)", + "GanttDragCalendarDays": "日历日", "ModeSelectorDisabledByFilter": "状态由活动筛选器控制。移除状态筛选器以使用此快捷方式。", "ShowQuickModeSelector": "快速筛选:全部 / 活跃 / 积压", "SearchScopeLabel": "搜索范围…", diff --git a/plugins/tracker-resources/src/components/gantt/GanttHelpPopup.svelte b/plugins/tracker-resources/src/components/gantt/GanttHelpPopup.svelte index 02793706339..1b3e1b6a03a 100644 --- a/plugins/tracker-resources/src/components/gantt/GanttHelpPopup.svelte +++ b/plugins/tracker-resources/src/components/gantt/GanttHelpPopup.svelte @@ -4,6 +4,7 @@ --> @@ -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 @@ -2012,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) - ) + dispatchDragMove(e.clientX, computeCanvasX(e), e.shiftKey) } async function handleCanvasPointerUp (e?: PointerEvent | MouseEvent): Promise { @@ -2025,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' }) @@ -2862,7 +2930,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, @@ -2870,11 +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: (child.startDate as number) + delta, - newDue: (child.dueDate as number) + delta + 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 @@ -2939,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 { @@ -2947,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. @@ -3066,21 +3143,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 +3265,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..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 @@ -3,8 +3,18 @@ // SPDX-License-Identifier: EPL-2.0 // -import { detectCycle, addScheduleDays, simulateCascade } from '../scheduler' import { + detectCycle, + addScheduleDays, + simulateCascade, + shiftScheduleDays, + shiftWithPrimary, + keyboardWeekStep +} from '../scheduler' +import { + createTimeScale, + dragCalendar, + reduce, isWorkingDay, fsAnchor, fsReverseAnchor, @@ -17,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 { @@ -98,6 +108,100 @@ 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)) + }) + + 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)) + }) + + 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) { 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