From 7f828cf8243547c7c3644cfea016ff9a73d92fcf Mon Sep 17 00:00:00 2001 From: Sergio Bur Date: Tue, 30 Dec 2025 10:36:57 +0100 Subject: [PATCH 01/10] fix: fix bug --- .../__tests__/workspace.base.test.ts | 72 ------- .../__tests__/workspace.recalculation.test.ts | 79 ------- .../scheduler/__tests__/workspace.test.ts | 198 ++++++++++++++++++ .../scheduler/workspaces/m_work_space.ts | 19 +- 4 files changed, 211 insertions(+), 157 deletions(-) delete mode 100644 packages/devextreme/js/__internal/scheduler/__tests__/workspace.base.test.ts delete mode 100644 packages/devextreme/js/__internal/scheduler/__tests__/workspace.recalculation.test.ts create mode 100644 packages/devextreme/js/__internal/scheduler/__tests__/workspace.test.ts diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/workspace.base.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/workspace.base.test.ts deleted file mode 100644 index 297f343f1769..000000000000 --- a/packages/devextreme/js/__internal/scheduler/__tests__/workspace.base.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { - describe, expect, it, jest, -} from '@jest/globals'; - -import { getResourceManagerMock } from '../__mock__/resource_manager.mock'; -import SchedulerTimelineDay from '../workspaces/m_timeline_day'; -import SchedulerTimelineMonth from '../workspaces/m_timeline_month'; -import SchedulerTimelineWeek from '../workspaces/m_timeline_week'; -import SchedulerTimelineWorkWeek from '../workspaces/m_timeline_work_week'; -import type SchedulerWorkSpace from '../workspaces/m_work_space'; -import SchedulerWorkSpaceDay from '../workspaces/m_work_space_day'; -import SchedulerWorkSpaceMonth from '../workspaces/m_work_space_month'; -import SchedulerWorkSpaceWeek from '../workspaces/m_work_space_week'; -import SchedulerWorkSpaceWorkWeek from '../workspaces/m_work_space_work_week'; - -type WorkspaceConstructor = new (container: Element, options?: any) => T; - -const createWorkspace = ( - WorkSpace: WorkspaceConstructor, - currentView: string, -): T => { - const container = document.createElement('div'); - const workspace = new WorkSpace(container, { - views: [currentView], - currentView, - currentDate: new Date(2017, 4, 25), - firstDayOfWeek: 0, - getResourceManager: () => getResourceManagerMock([]), - }); - (workspace as any)._isVisible = () => true; - expect(container.classList).toContain('dx-scheduler-work-space'); - - return workspace; -}; -const workSpaces: { - currentView: string; - WorkSpace: WorkspaceConstructor; -}[] = [ - { currentView: 'day', WorkSpace: SchedulerWorkSpaceDay }, - { currentView: 'week', WorkSpace: SchedulerWorkSpaceWeek }, - { currentView: 'workWeek', WorkSpace: SchedulerWorkSpaceWorkWeek }, - { currentView: 'month', WorkSpace: SchedulerWorkSpaceMonth }, - { currentView: 'timelineDay', WorkSpace: SchedulerTimelineDay }, - { currentView: 'timelineWeek', WorkSpace: SchedulerTimelineWeek }, - { currentView: 'timelineWorkWeek', WorkSpace: SchedulerTimelineWorkWeek }, - { currentView: 'timelineMonth', WorkSpace: SchedulerTimelineMonth }, -]; - -describe('scheduler workspace', () => { - workSpaces.forEach(({ currentView, WorkSpace }) => { - it(`should clear cache on dimension change, view: ${currentView}`, () => { - const workspace = createWorkspace(WorkSpace, currentView); - jest.spyOn(workspace.cache, 'clear'); - - workspace.cache.memo('test', () => 'value'); - workspace._dimensionChanged(); - - expect(workspace.cache.clear).toHaveBeenCalledTimes(1); - }); - - it(`should clear cache on _cleanView call, view: ${currentView}`, () => { - const workspace = createWorkspace(WorkSpace, currentView); - jest.spyOn(workspace.cache, 'clear'); - - workspace.cache.memo('test', () => 'value'); - workspace._cleanView(); - - expect(workspace.cache.clear).toHaveBeenCalledTimes(1); - expect(workspace.cache.size).toBe(0); - }); - }); -}); diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/workspace.recalculation.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/workspace.recalculation.test.ts deleted file mode 100644 index 13ffa2259716..000000000000 --- a/packages/devextreme/js/__internal/scheduler/__tests__/workspace.recalculation.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { - afterEach, beforeEach, describe, expect, it, -} from '@jest/globals'; -import $ from '@js/core/renderer'; - -import fx from '../../../common/core/animation/fx'; -import CustomStore from '../../../data/custom_store'; -import { createScheduler } from './__mock__/create_scheduler'; -import { setupSchedulerTestEnvironment } from './__mock__/m_mock_scheduler'; - -const CLASSES = { - scheduler: 'dx-scheduler', - workSpace: 'dx-scheduler-work-space', -}; - -describe('Workspace Recalculation with Async Templates (T661335)', () => { - beforeEach(() => { - fx.off = true; - setupSchedulerTestEnvironment({ height: 600 }); - }); - - afterEach(() => { - const $scheduler = $(document.querySelector(`.${CLASSES.scheduler}`)); - // @ts-expect-error - $scheduler.dxScheduler('dispose'); - document.body.innerHTML = ''; - fx.off = false; - }); - - it('should not duplicate workspace elements when resources are loaded asynchronously (T661335)', async () => { - const { scheduler, container } = await createScheduler({ - templatesRenderAsynchronously: true, - currentView: 'day', - views: ['day'], - groups: ['owner'], - resources: [ - { - fieldExpr: 'owner', - dataSource: [{ id: 1, text: 'Owner 1' }], - }, - { - fieldExpr: 'room', - dataSource: new CustomStore({ - load(): Promise { - return new Promise((resolve) => { - setTimeout(() => { - resolve([{ id: 1, text: 'Room 1', color: '#ff0000' }]); - }); - }); - }, - }), - }, - ], - dataSource: [ - { - text: 'Meeting in Room 1', - startDate: new Date(2017, 4, 25, 9, 0), - endDate: new Date(2017, 4, 25, 10, 0), - roomId: 1, - }, - ], - startDayHour: 9, - currentDate: new Date(2017, 4, 25), - height: 600, - }); - - scheduler.option('groups', ['room']); - - await new Promise((r) => { setTimeout(r); }); - - const $workSpaces = $(container).find(`.${CLASSES.workSpace}`); - const $groupHeader = $(container).find('.dx-scheduler-group-header'); - - expect($workSpaces.length).toBe(1); - - expect($groupHeader.length).toBeGreaterThan(0); - expect($groupHeader.text()).toContain('Room 1'); - }); -}); diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/workspace.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/workspace.test.ts new file mode 100644 index 000000000000..4934ad1ecd94 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/__tests__/workspace.test.ts @@ -0,0 +1,198 @@ +import { + afterEach, beforeEach, describe, expect, it, jest, +} from '@jest/globals'; +import $ from '@js/core/renderer'; +import { getWidth } from '@js/core/utils/size'; + +import fx from '../../../common/core/animation/fx'; +import CustomStore from '../../../data/custom_store'; +import { getResourceManagerMock } from '../__mock__/resource_manager.mock'; +import SchedulerTimelineDay from '../workspaces/m_timeline_day'; +import SchedulerTimelineMonth from '../workspaces/m_timeline_month'; +import SchedulerTimelineWeek from '../workspaces/m_timeline_week'; +import SchedulerTimelineWorkWeek from '../workspaces/m_timeline_work_week'; +import type SchedulerWorkSpace from '../workspaces/m_work_space'; +import SchedulerWorkSpaceDay from '../workspaces/m_work_space_day'; +import SchedulerWorkSpaceMonth from '../workspaces/m_work_space_month'; +import SchedulerWorkSpaceWeek from '../workspaces/m_work_space_week'; +import SchedulerWorkSpaceWorkWeek from '../workspaces/m_work_space_work_week'; +import { createScheduler } from './__mock__/create_scheduler'; +import { setupSchedulerTestEnvironment } from './__mock__/m_mock_scheduler'; + +const CLASSES = { + scheduler: 'dx-scheduler', + workSpace: 'dx-scheduler-work-space', +}; + +type WorkspaceConstructor = new (container: Element, options?: any) => T; + +const createWorkspace = ( + WorkSpace: WorkspaceConstructor, + currentView: string, +): T => { + const container = document.createElement('div'); + const workspace = new WorkSpace(container, { + views: [currentView], + currentView, + currentDate: new Date(2017, 4, 25), + firstDayOfWeek: 0, + getResourceManager: () => getResourceManagerMock([]), + }); + (workspace as any)._isVisible = () => true; + expect(container.classList).toContain('dx-scheduler-work-space'); + + return workspace; +}; + +const workSpaces: { + currentView: string; + WorkSpace: WorkspaceConstructor; +}[] = [ + { currentView: 'day', WorkSpace: SchedulerWorkSpaceDay }, + { currentView: 'week', WorkSpace: SchedulerWorkSpaceWeek }, + { currentView: 'workWeek', WorkSpace: SchedulerWorkSpaceWorkWeek }, + { currentView: 'month', WorkSpace: SchedulerWorkSpaceMonth }, + { currentView: 'timelineDay', WorkSpace: SchedulerTimelineDay }, + { currentView: 'timelineWeek', WorkSpace: SchedulerTimelineWeek }, + { currentView: 'timelineWorkWeek', WorkSpace: SchedulerTimelineWorkWeek }, + { currentView: 'timelineMonth', WorkSpace: SchedulerTimelineMonth }, +]; + +describe('Workspace', () => { + describe('Base functionality', () => { + workSpaces.forEach(({ currentView, WorkSpace }) => { + it(`should clear cache on dimension change, view: ${currentView}`, () => { + const workspace = createWorkspace(WorkSpace, currentView); + jest.spyOn(workspace.cache, 'clear'); + + workspace.cache.memo('test', () => 'value'); + workspace._dimensionChanged(); + + expect(workspace.cache.clear).toHaveBeenCalledTimes(1); + }); + + it(`should clear cache on _cleanView call, view: ${currentView}`, () => { + const workspace = createWorkspace(WorkSpace, currentView); + jest.spyOn(workspace.cache, 'clear'); + + workspace.cache.memo('test', () => 'value'); + workspace._cleanView(); + + expect(workspace.cache.clear).toHaveBeenCalledTimes(1); + expect(workspace.cache.size).toBe(0); + }); + }); + }); + + describe('Recalculation with Async Templates', () => { + beforeEach(() => { + fx.off = true; + setupSchedulerTestEnvironment({ height: 600 }); + }); + + afterEach(() => { + const $scheduler = $(document.querySelector(`.${CLASSES.scheduler}`)); + // @ts-expect-error + $scheduler.dxScheduler('dispose'); + document.body.innerHTML = ''; + fx.off = false; + }); + + it('should not duplicate workspace elements when resources are loaded asynchronously (T661335)', async () => { + const { scheduler, container } = await createScheduler({ + templatesRenderAsynchronously: true, + currentView: 'day', + views: ['day'], + groups: ['owner'], + resources: [ + { + fieldExpr: 'owner', + dataSource: [{ id: 1, text: 'Owner 1' }], + }, + { + fieldExpr: 'room', + dataSource: new CustomStore({ + load(): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve([{ id: 1, text: 'Room 1', color: '#ff0000' }]); + }); + }); + }, + }), + }, + ], + dataSource: [ + { + text: 'Meeting in Room 1', + startDate: new Date(2017, 4, 25, 9, 0), + endDate: new Date(2017, 4, 25, 10, 0), + roomId: 1, + }, + ], + startDayHour: 9, + currentDate: new Date(2017, 4, 25), + height: 600, + }); + + scheduler.option('groups', ['room']); + + await new Promise((r) => { setTimeout(r); }); + + const $workSpaces = $(container).find(`.${CLASSES.workSpace}`); + const $groupHeader = $(container).find('.dx-scheduler-group-header'); + + expect($workSpaces.length).toBe(1); + + expect($groupHeader.length).toBeGreaterThan(0); + expect($groupHeader.text()).toContain('Room 1'); + }); + }); + + describe('scrollTo', () => { + beforeEach(() => { + fx.off = true; + setupSchedulerTestEnvironment({ height: 600 }); + }); + + afterEach(() => { + document.body.innerHTML = ''; + fx.off = false; + }); + + it('should scroll to date with offset (T1310544)', async () => { + const { scheduler } = await createScheduler({ + views: ['timelineDay'], + currentView: 'timelineDay', + currentDate: new Date(2021, 1, 2), + firstDayOfWeek: 0, + startDayHour: 6, + endDayHour: 18, + offset: 720, + cellDuration: 60, + height: 580, + }); + + const workspace = scheduler.getWorkSpace(); + const scrollable = workspace.getScrollable(); + const $scrollable = scrollable.$element(); + const scrollBySpy = jest.spyOn(scrollable, 'scrollBy'); + + // With offset: 720 (12 hours), cells start at 18:00 (6:00 + 12h) + // For date 22:00, this should be cell index 4 (18:00=0, 19:00=1, 20:00=2, 21:00=3, 22:00=4) + const leftCellCount = 4; + const cellWidth = workspace.getCellWidth(); + const scrollableWidth = getWidth($scrollable); + const expectedLeft = leftCellCount * cellWidth - (scrollableWidth - cellWidth) / 2; + + const targetDate = new Date(2021, 1, 2, 22, 0); + scheduler.scrollTo(targetDate, undefined, false); + + expect(scrollBySpy).toHaveBeenCalledTimes(1); + const scrollParams = scrollBySpy.mock.calls[0][0] as { left: number; top: number }; + expect(scrollParams.left).toBeCloseTo(expectedLeft, 1); + + scrollBySpy.mockRestore(); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts index c1649f57b664..aed9cc16935c 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts @@ -1411,13 +1411,16 @@ class SchedulerWorkSpace extends Widget { const currentDate = date || new Date(this.option('currentDate')); const startDayHour = this.option('startDayHour'); const endDayHour = this.option('endDayHour'); + const viewOffset = this.option('viewOffset'); - if (hours < startDayHour) { - hours = startDayHour; - } + if (viewOffset === 0) { + if (hours < startDayHour) { + hours = startDayHour; + } - if (hours >= endDayHour) { - hours = endDayHour - 1; + if (hours >= endDayHour) { + hours = endDayHour - 1; + } } currentDate.setHours(hours, minutes, 0, 0); @@ -1865,10 +1868,14 @@ class SchedulerWorkSpace extends Widget { } _isValidScrollDate(date, throwWarning = true) { + const viewOffset = this.option('viewOffset') as number; const min = this.getStartViewDate(); const max = this.getEndViewDate(); - if (date < min || date > max) { + const extendedMin = new Date(min.getTime() - viewOffset); + const extendedMax = new Date(max.getTime() + viewOffset); + + if (date < extendedMin || date > extendedMax) { throwWarning && errors.log('W1008', date); return false; } From 37a82c0cbc9a284898895756c2f2ea93a0561d4d Mon Sep 17 00:00:00 2001 From: Sergio Bur Date: Mon, 5 Jan 2026 08:43:29 +0100 Subject: [PATCH 02/10] test: reerrange tests --- .../__tests__/workspace.base.test.ts | 72 ++++++++++++++++++ .../scheduler/__tests__/workspace.test.ts | 75 +------------------ 2 files changed, 75 insertions(+), 72 deletions(-) create mode 100644 packages/devextreme/js/__internal/scheduler/__tests__/workspace.base.test.ts diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/workspace.base.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/workspace.base.test.ts new file mode 100644 index 000000000000..297f343f1769 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/__tests__/workspace.base.test.ts @@ -0,0 +1,72 @@ +import { + describe, expect, it, jest, +} from '@jest/globals'; + +import { getResourceManagerMock } from '../__mock__/resource_manager.mock'; +import SchedulerTimelineDay from '../workspaces/m_timeline_day'; +import SchedulerTimelineMonth from '../workspaces/m_timeline_month'; +import SchedulerTimelineWeek from '../workspaces/m_timeline_week'; +import SchedulerTimelineWorkWeek from '../workspaces/m_timeline_work_week'; +import type SchedulerWorkSpace from '../workspaces/m_work_space'; +import SchedulerWorkSpaceDay from '../workspaces/m_work_space_day'; +import SchedulerWorkSpaceMonth from '../workspaces/m_work_space_month'; +import SchedulerWorkSpaceWeek from '../workspaces/m_work_space_week'; +import SchedulerWorkSpaceWorkWeek from '../workspaces/m_work_space_work_week'; + +type WorkspaceConstructor = new (container: Element, options?: any) => T; + +const createWorkspace = ( + WorkSpace: WorkspaceConstructor, + currentView: string, +): T => { + const container = document.createElement('div'); + const workspace = new WorkSpace(container, { + views: [currentView], + currentView, + currentDate: new Date(2017, 4, 25), + firstDayOfWeek: 0, + getResourceManager: () => getResourceManagerMock([]), + }); + (workspace as any)._isVisible = () => true; + expect(container.classList).toContain('dx-scheduler-work-space'); + + return workspace; +}; +const workSpaces: { + currentView: string; + WorkSpace: WorkspaceConstructor; +}[] = [ + { currentView: 'day', WorkSpace: SchedulerWorkSpaceDay }, + { currentView: 'week', WorkSpace: SchedulerWorkSpaceWeek }, + { currentView: 'workWeek', WorkSpace: SchedulerWorkSpaceWorkWeek }, + { currentView: 'month', WorkSpace: SchedulerWorkSpaceMonth }, + { currentView: 'timelineDay', WorkSpace: SchedulerTimelineDay }, + { currentView: 'timelineWeek', WorkSpace: SchedulerTimelineWeek }, + { currentView: 'timelineWorkWeek', WorkSpace: SchedulerTimelineWorkWeek }, + { currentView: 'timelineMonth', WorkSpace: SchedulerTimelineMonth }, +]; + +describe('scheduler workspace', () => { + workSpaces.forEach(({ currentView, WorkSpace }) => { + it(`should clear cache on dimension change, view: ${currentView}`, () => { + const workspace = createWorkspace(WorkSpace, currentView); + jest.spyOn(workspace.cache, 'clear'); + + workspace.cache.memo('test', () => 'value'); + workspace._dimensionChanged(); + + expect(workspace.cache.clear).toHaveBeenCalledTimes(1); + }); + + it(`should clear cache on _cleanView call, view: ${currentView}`, () => { + const workspace = createWorkspace(WorkSpace, currentView); + jest.spyOn(workspace.cache, 'clear'); + + workspace.cache.memo('test', () => 'value'); + workspace._cleanView(); + + expect(workspace.cache.clear).toHaveBeenCalledTimes(1); + expect(workspace.cache.size).toBe(0); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/workspace.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/workspace.test.ts index 4934ad1ecd94..856c638eeef5 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/workspace.test.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/workspace.test.ts @@ -6,16 +6,6 @@ import { getWidth } from '@js/core/utils/size'; import fx from '../../../common/core/animation/fx'; import CustomStore from '../../../data/custom_store'; -import { getResourceManagerMock } from '../__mock__/resource_manager.mock'; -import SchedulerTimelineDay from '../workspaces/m_timeline_day'; -import SchedulerTimelineMonth from '../workspaces/m_timeline_month'; -import SchedulerTimelineWeek from '../workspaces/m_timeline_week'; -import SchedulerTimelineWorkWeek from '../workspaces/m_timeline_work_week'; -import type SchedulerWorkSpace from '../workspaces/m_work_space'; -import SchedulerWorkSpaceDay from '../workspaces/m_work_space_day'; -import SchedulerWorkSpaceMonth from '../workspaces/m_work_space_month'; -import SchedulerWorkSpaceWeek from '../workspaces/m_work_space_week'; -import SchedulerWorkSpaceWorkWeek from '../workspaces/m_work_space_work_week'; import { createScheduler } from './__mock__/create_scheduler'; import { setupSchedulerTestEnvironment } from './__mock__/m_mock_scheduler'; @@ -24,67 +14,8 @@ const CLASSES = { workSpace: 'dx-scheduler-work-space', }; -type WorkspaceConstructor = new (container: Element, options?: any) => T; - -const createWorkspace = ( - WorkSpace: WorkspaceConstructor, - currentView: string, -): T => { - const container = document.createElement('div'); - const workspace = new WorkSpace(container, { - views: [currentView], - currentView, - currentDate: new Date(2017, 4, 25), - firstDayOfWeek: 0, - getResourceManager: () => getResourceManagerMock([]), - }); - (workspace as any)._isVisible = () => true; - expect(container.classList).toContain('dx-scheduler-work-space'); - - return workspace; -}; - -const workSpaces: { - currentView: string; - WorkSpace: WorkspaceConstructor; -}[] = [ - { currentView: 'day', WorkSpace: SchedulerWorkSpaceDay }, - { currentView: 'week', WorkSpace: SchedulerWorkSpaceWeek }, - { currentView: 'workWeek', WorkSpace: SchedulerWorkSpaceWorkWeek }, - { currentView: 'month', WorkSpace: SchedulerWorkSpaceMonth }, - { currentView: 'timelineDay', WorkSpace: SchedulerTimelineDay }, - { currentView: 'timelineWeek', WorkSpace: SchedulerTimelineWeek }, - { currentView: 'timelineWorkWeek', WorkSpace: SchedulerTimelineWorkWeek }, - { currentView: 'timelineMonth', WorkSpace: SchedulerTimelineMonth }, -]; - describe('Workspace', () => { - describe('Base functionality', () => { - workSpaces.forEach(({ currentView, WorkSpace }) => { - it(`should clear cache on dimension change, view: ${currentView}`, () => { - const workspace = createWorkspace(WorkSpace, currentView); - jest.spyOn(workspace.cache, 'clear'); - - workspace.cache.memo('test', () => 'value'); - workspace._dimensionChanged(); - - expect(workspace.cache.clear).toHaveBeenCalledTimes(1); - }); - - it(`should clear cache on _cleanView call, view: ${currentView}`, () => { - const workspace = createWorkspace(WorkSpace, currentView); - jest.spyOn(workspace.cache, 'clear'); - - workspace.cache.memo('test', () => 'value'); - workspace._cleanView(); - - expect(workspace.cache.clear).toHaveBeenCalledTimes(1); - expect(workspace.cache.size).toBe(0); - }); - }); - }); - - describe('Recalculation with Async Templates', () => { + describe('Recalculation with Async Templates (T661335)', () => { beforeEach(() => { fx.off = true; setupSchedulerTestEnvironment({ height: 600 }); @@ -149,7 +80,7 @@ describe('Workspace', () => { }); }); - describe('scrollTo', () => { + describe('scrollTo (T1310544)', () => { beforeEach(() => { fx.off = true; setupSchedulerTestEnvironment({ height: 600 }); @@ -160,7 +91,7 @@ describe('Workspace', () => { fx.off = false; }); - it('should scroll to date with offset (T1310544)', async () => { + it('T1310544: should scroll to date with offset: 720 (12 hours)', async () => { const { scheduler } = await createScheduler({ views: ['timelineDay'], currentView: 'timelineDay', From d67c7dffa859f9b4a8e96a19ac6a4e9ccc6142a0 Mon Sep 17 00:00:00 2001 From: Sergio Bur Date: Thu, 8 Jan 2026 18:21:36 +0100 Subject: [PATCH 03/10] feat: try to fix bug with new strategy --- .../scheduler/__tests__/workspace.test.ts | 106 ++++++++++++- .../scheduler/workspaces/m_work_space.ts | 141 ++++++++++++++---- 2 files changed, 219 insertions(+), 28 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/workspace.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/workspace.test.ts index 856c638eeef5..eeebc35e6c2c 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/workspace.test.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/workspace.test.ts @@ -80,7 +80,7 @@ describe('Workspace', () => { }); }); - describe('scrollTo (T1310544)', () => { + describe('scrollTo', () => { beforeEach(() => { fx.off = true; setupSchedulerTestEnvironment({ height: 600 }); @@ -125,5 +125,109 @@ describe('Workspace', () => { scrollBySpy.mockRestore(); }); + + describe('hour normalization', () => { + it('should normalize hours to visible range without viewOffset', async () => { + const { scheduler } = await createScheduler({ + views: ['timelineDay'], + currentView: 'timelineDay', + currentDate: new Date(2021, 1, 2), + startDayHour: 6, + endDayHour: 18, + offset: 0, + }); + + const workspace = scheduler.getWorkSpace(); + const scrollable = workspace.getScrollable(); + const scrollBySpy = jest.spyOn(scrollable, 'scrollBy'); + + // Below startDayHour (6), should normalize to 6 + const dateBelowRange = new Date(2021, 1, 2, 4, 0); + scheduler.scrollTo(dateBelowRange, undefined, false); + expect(scrollBySpy).toHaveBeenCalled(); + + scrollBySpy.mockClear(); + // Above endDayHour (18), should normalize to 17 + const dateAboveRange = new Date(2021, 1, 2, 20, 0); + scheduler.scrollTo(dateAboveRange, undefined, false); + expect(scrollBySpy).toHaveBeenCalled(); + + scrollBySpy.mockClear(); + // Within range [6, 18), should scroll normally + const dateInRange = new Date(2021, 1, 2, 12, 0); + scheduler.scrollTo(dateInRange, undefined, false); + expect(scrollBySpy).toHaveBeenCalled(); + + scrollBySpy.mockRestore(); + }); + + it('should normalize hours to visible range with viewOffset (no midnight crossing)', async () => { + const { scheduler } = await createScheduler({ + views: ['timelineDay'], + currentView: 'timelineDay', + currentDate: new Date(2021, 1, 2), + startDayHour: 6, + endDayHour: 18, + offset: 360, + }); + + const workspace = scheduler.getWorkSpace(); + const scrollable = workspace.getScrollable(); + const scrollBySpy = jest.spyOn(scrollable, 'scrollBy'); + + // Below adjustedStartDayHour (12), should normalize to 12 + const dateBelowAdjustedRange = new Date(2021, 1, 2, 10, 0); + scheduler.scrollTo(dateBelowAdjustedRange, undefined, false); + expect(scrollBySpy).toHaveBeenCalled(); + + scrollBySpy.mockClear(); + // Within adjusted range [12, 24), should scroll normally + const dateInAdjustedRange = new Date(2021, 1, 2, 15, 0); + scheduler.scrollTo(dateInAdjustedRange, undefined, false); + expect(scrollBySpy).toHaveBeenCalled(); + + scrollBySpy.mockRestore(); + }); + + it('should normalize hours to visible range with viewOffset (midnight crossing)', async () => { + const { scheduler } = await createScheduler({ + views: ['timelineDay'], + currentView: 'timelineDay', + currentDate: new Date(2021, 1, 2), + startDayHour: 6, + endDayHour: 18, + offset: 720, + }); + + const workspace = scheduler.getWorkSpace(); + const scrollable = workspace.getScrollable(); + const scrollBySpy = jest.spyOn(scrollable, 'scrollBy'); + + // In gap [6, 18), should normalize to 18:00 Feb 2 + const dateInGap = new Date(2021, 1, 2, 10, 0); + scheduler.scrollTo(dateInGap, undefined, false); + expect(scrollBySpy).toHaveBeenCalled(); + + scrollBySpy.mockClear(); + // In range [18, 24) on Feb 2, should scroll normally + const dateInFirstRange = new Date(2021, 1, 2, 22, 0); + scheduler.scrollTo(dateInFirstRange, undefined, false); + expect(scrollBySpy).toHaveBeenCalled(); + + scrollBySpy.mockClear(); + // In range [0, 6) but on wrong day (Feb 2), should normalize to 18:00 Feb 2 + const dateInSecondRangeWrongDay = new Date(2021, 1, 2, 3, 0); + scheduler.scrollTo(dateInSecondRangeWrongDay, undefined, false); + expect(scrollBySpy).toHaveBeenCalled(); + + scrollBySpy.mockClear(); + // In range [0, 6) on correct day (Feb 3), should scroll normally + const dateInSecondRangeCorrectDay = new Date(2021, 1, 3, 3, 0); + scheduler.scrollTo(dateInSecondRangeCorrectDay, undefined, false); + expect(scrollBySpy).toHaveBeenCalled(); + + scrollBySpy.mockRestore(); + }); + }); }); }); diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts index aed9cc16935c..1434455fa7af 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts @@ -691,8 +691,8 @@ class SchedulerWorkSpace extends Widget { } if (this.isVirtualScrolling() - && (this.virtualScrollingDispatcher.horizontalScrollingAllowed - || this.virtualScrollingDispatcher.height)) { + && (this.virtualScrollingDispatcher.horizontalScrollingAllowed + || this.virtualScrollingDispatcher.height)) { const currentOnScroll = config.onScroll; config = { ...config, @@ -1411,7 +1411,8 @@ class SchedulerWorkSpace extends Widget { const currentDate = date || new Date(this.option('currentDate')); const startDayHour = this.option('startDayHour'); const endDayHour = this.option('endDayHour'); - const viewOffset = this.option('viewOffset'); + const viewOffset = this.option('viewOffset') as number; + const viewOffsetHours = viewOffset / HOUR_MS; if (viewOffset === 0) { if (hours < startDayHour) { @@ -1421,6 +1422,60 @@ class SchedulerWorkSpace extends Widget { if (hours >= endDayHour) { hours = endDayHour - 1; } + } else { + const adjustedStartDayHour = (startDayHour + viewOffsetHours) % 24; + const adjustedEndDayHour = (endDayHour + viewOffsetHours) % 24; + + // If adjustedEndDayHour is 0, it means the range is [adjustedStartDayHour, 24), not crossing midnight + // Only consider it crossing midnight if adjustedEndDayHour > 0 and adjustedStartDayHour >= adjustedEndDayHour + const crossesMidnight = adjustedEndDayHour > 0 && adjustedStartDayHour >= adjustedEndDayHour; + + // If adjustedEndDayHour is 0, treat it as 24 for range comparison + const effectiveEndDayHour = adjustedEndDayHour === 0 ? 24 : adjustedEndDayHour; + + switch (true) { + case crossesMidnight: + // Boundaries cross midnight: [adjustedStartDayHour, 24) ∪ [0, adjustedEndDayHour) + // The second range [0, adjustedEndDayHour) belongs to the next day + if (hours >= adjustedStartDayHour) { + // Hours are in first range [adjustedStartDayHour, 24), keep original hours + } else if (hours < adjustedEndDayHour) { + // Hours are in second range [0, adjustedEndDayHour) + // This range belongs to the next day relative to the start of visible range + // Check if currentDate is the next day by comparing with startViewDate + const startViewDate = this.getStartViewDate(); + const nextDayDate = new Date(startViewDate); + nextDayDate.setDate(nextDayDate.getDate() + 1); + + // Check if currentDate is the next day (same year, month, date) + const isNextDay = currentDate.getFullYear() === nextDayDate.getFullYear() + && currentDate.getMonth() === nextDayDate.getMonth() + && currentDate.getDate() === nextDayDate.getDate(); + + if (!isNextDay) { + // Current date is not the next day, normalize to adjustedStartDayHour on current date + hours = adjustedStartDayHour; + } + // If isNextDay is true, keep original hours + } else { + // Hours are in gap [adjustedEndDayHour, adjustedStartDayHour), normalize to adjustedStartDayHour + hours = adjustedStartDayHour; + } + break; + + case hours < adjustedStartDayHour: + // Normal case: boundaries don't cross midnight [adjustedStartDayHour, effectiveEndDayHour) + hours = adjustedStartDayHour; + break; + + case hours >= effectiveEndDayHour: + hours = effectiveEndDayHour - 1; + break; + + default: + // Hours are in valid range, keep original hours + break; + } } currentDate.setHours(hours, minutes, 0, 0); @@ -1514,8 +1569,8 @@ class SchedulerWorkSpace extends Widget { isGroupedByDate() { return this.option('groupByDate') - && this._isHorizontalGroupedWorkSpace() - && this._getGroupCount() > 0; + && this._isHorizontalGroupedWorkSpace() + && this._getGroupCount() > 0; } // TODO: refactor current time indicator @@ -1773,9 +1828,9 @@ class SchedulerWorkSpace extends Widget { const cellEndTime = cellEndDate.getTime(); if (((!inAllDayRow && cellStartTime <= time - && time < cellEndTime) - || (inAllDayRow && trimmedTime === cellStartTime)) - && groupIndex === cellGroupIndex) { + && time < cellEndTime) + || (inAllDayRow && trimmedTime === cellStartTime)) + && groupIndex === cellGroupIndex) { return false; } return currentResult; @@ -1816,9 +1871,9 @@ class SchedulerWorkSpace extends Widget { const rowIndex = index / totalColumnCount; if (scrolledColumnCount <= columnIndex - && columnIndex < columnCount - && scrolledRowCount <= rowIndex - && rowIndex < rowCount) { + && columnIndex < columnCount + && scrolledRowCount <= rowIndex + && rowIndex < rowCount) { result.push($cell); } }); @@ -1871,16 +1926,48 @@ class SchedulerWorkSpace extends Widget { const viewOffset = this.option('viewOffset') as number; const min = this.getStartViewDate(); const max = this.getEndViewDate(); - - const extendedMin = new Date(min.getTime() - viewOffset); - const extendedMax = new Date(max.getTime() + viewOffset); - - if (date < extendedMin || date > extendedMax) { - throwWarning && errors.log('W1008', date); - return false; + const startDayHour = this.option('startDayHour'); + const endDayHour = this.option('endDayHour'); + const viewOffsetHours = viewOffset / HOUR_MS; + + // Check only the date range, not the time + // Time will be normalized in _getScrollCoordinates + const minDate = new Date(min); + minDate.setHours(0, 0, 0, 0); + const maxDate = new Date(max); + maxDate.setHours(23, 59, 59, 999); + + const dateOnly = new Date(date); + dateOnly.setHours(0, 0, 0, 0); + + // Check if date is within the basic range [minDate, maxDate] + if (dateOnly >= minDate && dateOnly <= maxDate) { + return true; + } + + // With viewOffset, if range crosses midnight, allow the next day + if (viewOffset !== 0) { + const adjustedStartDayHour = (startDayHour + viewOffsetHours) % 24; + const adjustedEndDayHour = (endDayHour + viewOffsetHours) % 24; + const crossesMidnight = adjustedEndDayHour > 0 && adjustedStartDayHour >= adjustedEndDayHour; + + if (crossesMidnight) { + // Range crosses midnight: [adjustedStartDayHour, 24) ∪ [0, adjustedEndDayHour) + // Allow the next day after maxDate + const nextDayMin = new Date(maxDate); + nextDayMin.setDate(nextDayMin.getDate() + 1); + nextDayMin.setHours(0, 0, 0, 0); + const nextDayMax = new Date(nextDayMin); + nextDayMax.setHours(23, 59, 59, 999); + + if (dateOnly >= nextDayMin && dateOnly <= nextDayMax) { + return true; + } + } } - return true; + throwWarning && errors.log('W1008', date); + return false; } needApplyCollectorOffset() { @@ -2316,11 +2403,11 @@ class SchedulerWorkSpace extends Widget { renovateRender: true, height: undefined, draggingMode: 'outlook', - onScrollEnd: () => {}, + onScrollEnd: () => { }, getHeaderHeight: undefined, - onRenderAppointments: () => {}, - onShowAllDayPanel: () => {}, - onSelectedCellsClick: () => {}, + onRenderAppointments: () => { }, + onShowAllDayPanel: () => { }, + onSelectedCellsClick: () => { }, timeZoneCalculator: undefined, schedulerHeight: undefined, schedulerWidth: undefined, @@ -3367,10 +3454,10 @@ const createDragBehaviorConfig = ( const isCurrentSchedulerElement = dateTables.find(el).length === 1; return isCurrentSchedulerElement - && ( - classList.contains(DATE_TABLE_CELL_CLASS) - || classList.contains(ALL_DAY_TABLE_CELL_CLASS) - ); + && ( + classList.contains(DATE_TABLE_CELL_CLASS) + || classList.contains(ALL_DAY_TABLE_CELL_CLASS) + ); }); if (droppableCell) { From 3962f46f9bd914bdac5cd7dea47687b7da0504c1 Mon Sep 17 00:00:00 2001 From: Sergio Bur Date: Thu, 8 Jan 2026 18:27:55 +0100 Subject: [PATCH 04/10] fix: fix indents --- .../scheduler/workspaces/m_work_space.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts index 1434455fa7af..9d08faef4f9d 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts @@ -1463,8 +1463,8 @@ class SchedulerWorkSpace extends Widget { } break; + // Normal case: boundaries don't cross midnight [adjustedStartDayHour, effectiveEndDayHour) case hours < adjustedStartDayHour: - // Normal case: boundaries don't cross midnight [adjustedStartDayHour, effectiveEndDayHour) hours = adjustedStartDayHour; break; @@ -2403,11 +2403,11 @@ class SchedulerWorkSpace extends Widget { renovateRender: true, height: undefined, draggingMode: 'outlook', - onScrollEnd: () => { }, + onScrollEnd: () => {}, getHeaderHeight: undefined, - onRenderAppointments: () => { }, - onShowAllDayPanel: () => { }, - onSelectedCellsClick: () => { }, + onRenderAppointments: () => {}, + onShowAllDayPanel: () => {}, + onSelectedCellsClick: () => {}, timeZoneCalculator: undefined, schedulerHeight: undefined, schedulerWidth: undefined, @@ -3454,10 +3454,10 @@ const createDragBehaviorConfig = ( const isCurrentSchedulerElement = dateTables.find(el).length === 1; return isCurrentSchedulerElement - && ( - classList.contains(DATE_TABLE_CELL_CLASS) - || classList.contains(ALL_DAY_TABLE_CELL_CLASS) - ); + && ( + classList.contains(DATE_TABLE_CELL_CLASS) + || classList.contains(ALL_DAY_TABLE_CELL_CLASS) + ); }); if (droppableCell) { From 4565841a5ee10c843883f2c368e75d791c2c6be8 Mon Sep 17 00:00:00 2001 From: Sergio Bur Date: Fri, 9 Jan 2026 08:55:51 +0100 Subject: [PATCH 05/10] fix: fix indents --- .../scheduler/workspaces/m_work_space.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts index 9d08faef4f9d..3a318e58420d 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts @@ -691,8 +691,8 @@ class SchedulerWorkSpace extends Widget { } if (this.isVirtualScrolling() - && (this.virtualScrollingDispatcher.horizontalScrollingAllowed - || this.virtualScrollingDispatcher.height)) { + && (this.virtualScrollingDispatcher.horizontalScrollingAllowed + || this.virtualScrollingDispatcher.height)) { const currentOnScroll = config.onScroll; config = { ...config, @@ -1569,8 +1569,8 @@ class SchedulerWorkSpace extends Widget { isGroupedByDate() { return this.option('groupByDate') - && this._isHorizontalGroupedWorkSpace() - && this._getGroupCount() > 0; + && this._isHorizontalGroupedWorkSpace() + && this._getGroupCount() > 0; } // TODO: refactor current time indicator @@ -1828,9 +1828,9 @@ class SchedulerWorkSpace extends Widget { const cellEndTime = cellEndDate.getTime(); if (((!inAllDayRow && cellStartTime <= time - && time < cellEndTime) - || (inAllDayRow && trimmedTime === cellStartTime)) - && groupIndex === cellGroupIndex) { + && time < cellEndTime) + || (inAllDayRow && trimmedTime === cellStartTime)) + && groupIndex === cellGroupIndex) { return false; } return currentResult; @@ -1871,9 +1871,9 @@ class SchedulerWorkSpace extends Widget { const rowIndex = index / totalColumnCount; if (scrolledColumnCount <= columnIndex - && columnIndex < columnCount - && scrolledRowCount <= rowIndex - && rowIndex < rowCount) { + && columnIndex < columnCount + && scrolledRowCount <= rowIndex + && rowIndex < rowCount) { result.push($cell); } }); From 14b1190364bdfc3159423187082630b1fe61a529 Mon Sep 17 00:00:00 2001 From: Sergio Bur Date: Fri, 9 Jan 2026 08:59:56 +0100 Subject: [PATCH 06/10] fix: short comments --- .../scheduler/workspaces/m_work_space.ts | 27 ++----------- packages/devextreme/playground/jquery.html | 40 +++++++++++++++---- 2 files changed, 37 insertions(+), 30 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts index 3a318e58420d..d0a4890f8a3f 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts @@ -1425,45 +1425,32 @@ class SchedulerWorkSpace extends Widget { } else { const adjustedStartDayHour = (startDayHour + viewOffsetHours) % 24; const adjustedEndDayHour = (endDayHour + viewOffsetHours) % 24; - - // If adjustedEndDayHour is 0, it means the range is [adjustedStartDayHour, 24), not crossing midnight - // Only consider it crossing midnight if adjustedEndDayHour > 0 and adjustedStartDayHour >= adjustedEndDayHour const crossesMidnight = adjustedEndDayHour > 0 && adjustedStartDayHour >= adjustedEndDayHour; - - // If adjustedEndDayHour is 0, treat it as 24 for range comparison const effectiveEndDayHour = adjustedEndDayHour === 0 ? 24 : adjustedEndDayHour; switch (true) { case crossesMidnight: - // Boundaries cross midnight: [adjustedStartDayHour, 24) ∪ [0, adjustedEndDayHour) - // The second range [0, adjustedEndDayHour) belongs to the next day + // When range crosses midnight: [adjustedStartDayHour, 24) ∪ [0, adjustedEndDayHour) + // Hours in [0, adjustedEndDayHour) are valid only on the next day after startViewDate if (hours >= adjustedStartDayHour) { - // Hours are in first range [adjustedStartDayHour, 24), keep original hours + // Hours in first range [adjustedStartDayHour, 24) - keep original hours } else if (hours < adjustedEndDayHour) { - // Hours are in second range [0, adjustedEndDayHour) - // This range belongs to the next day relative to the start of visible range - // Check if currentDate is the next day by comparing with startViewDate const startViewDate = this.getStartViewDate(); const nextDayDate = new Date(startViewDate); nextDayDate.setDate(nextDayDate.getDate() + 1); - // Check if currentDate is the next day (same year, month, date) const isNextDay = currentDate.getFullYear() === nextDayDate.getFullYear() && currentDate.getMonth() === nextDayDate.getMonth() && currentDate.getDate() === nextDayDate.getDate(); if (!isNextDay) { - // Current date is not the next day, normalize to adjustedStartDayHour on current date hours = adjustedStartDayHour; } - // If isNextDay is true, keep original hours } else { - // Hours are in gap [adjustedEndDayHour, adjustedStartDayHour), normalize to adjustedStartDayHour hours = adjustedStartDayHour; } break; - // Normal case: boundaries don't cross midnight [adjustedStartDayHour, effectiveEndDayHour) case hours < adjustedStartDayHour: hours = adjustedStartDayHour; break; @@ -1473,7 +1460,6 @@ class SchedulerWorkSpace extends Widget { break; default: - // Hours are in valid range, keep original hours break; } } @@ -1930,8 +1916,6 @@ class SchedulerWorkSpace extends Widget { const endDayHour = this.option('endDayHour'); const viewOffsetHours = viewOffset / HOUR_MS; - // Check only the date range, not the time - // Time will be normalized in _getScrollCoordinates const minDate = new Date(min); minDate.setHours(0, 0, 0, 0); const maxDate = new Date(max); @@ -1940,20 +1924,17 @@ class SchedulerWorkSpace extends Widget { const dateOnly = new Date(date); dateOnly.setHours(0, 0, 0, 0); - // Check if date is within the basic range [minDate, maxDate] if (dateOnly >= minDate && dateOnly <= maxDate) { return true; } - // With viewOffset, if range crosses midnight, allow the next day + // When viewOffset causes range to cross midnight, allow the next day after maxDate if (viewOffset !== 0) { const adjustedStartDayHour = (startDayHour + viewOffsetHours) % 24; const adjustedEndDayHour = (endDayHour + viewOffsetHours) % 24; const crossesMidnight = adjustedEndDayHour > 0 && adjustedStartDayHour >= adjustedEndDayHour; if (crossesMidnight) { - // Range crosses midnight: [adjustedStartDayHour, 24) ∪ [0, adjustedEndDayHour) - // Allow the next day after maxDate const nextDayMin = new Date(maxDate); nextDayMin.setDate(nextDayMin.getDate() + 1); nextDayMin.setHours(0, 0, 0, 0); diff --git a/packages/devextreme/playground/jquery.html b/packages/devextreme/playground/jquery.html index abaed02d03f2..37fac9671fab 100644 --- a/packages/devextreme/playground/jquery.html +++ b/packages/devextreme/playground/jquery.html @@ -49,14 +49,40 @@

Te
-
+
From 0ac5ddd88779599f1115b3688d2dfddf90dabdd5 Mon Sep 17 00:00:00 2001 From: Sergio Bur Date: Fri, 9 Jan 2026 09:02:21 +0100 Subject: [PATCH 07/10] fix: revert jquery --- packages/devextreme/playground/jquery.html | 40 ++++------------------ 1 file changed, 7 insertions(+), 33 deletions(-) diff --git a/packages/devextreme/playground/jquery.html b/packages/devextreme/playground/jquery.html index 37fac9671fab..abaed02d03f2 100644 --- a/packages/devextreme/playground/jquery.html +++ b/packages/devextreme/playground/jquery.html @@ -49,40 +49,14 @@

Te
-
+
From 529130c9ae0a397ae3aec77a519e5578f631c484 Mon Sep 17 00:00:00 2001 From: Sergio Bur Date: Fri, 9 Jan 2026 09:17:56 +0100 Subject: [PATCH 08/10] feat: optimize code --- .../scheduler/workspaces/m_work_space.ts | 99 ++++++++----------- 1 file changed, 42 insertions(+), 57 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts index d0a4890f8a3f..4fc5798949ad 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts @@ -1414,54 +1414,44 @@ class SchedulerWorkSpace extends Widget { const viewOffset = this.option('viewOffset') as number; const viewOffsetHours = viewOffset / HOUR_MS; - if (viewOffset === 0) { - if (hours < startDayHour) { - hours = startDayHour; - } - - if (hours >= endDayHour) { - hours = endDayHour - 1; - } - } else { - const adjustedStartDayHour = (startDayHour + viewOffsetHours) % 24; - const adjustedEndDayHour = (endDayHour + viewOffsetHours) % 24; - const crossesMidnight = adjustedEndDayHour > 0 && adjustedStartDayHour >= adjustedEndDayHour; - const effectiveEndDayHour = adjustedEndDayHour === 0 ? 24 : adjustedEndDayHour; - - switch (true) { - case crossesMidnight: - // When range crosses midnight: [adjustedStartDayHour, 24) ∪ [0, adjustedEndDayHour) - // Hours in [0, adjustedEndDayHour) are valid only on the next day after startViewDate - if (hours >= adjustedStartDayHour) { - // Hours in first range [adjustedStartDayHour, 24) - keep original hours - } else if (hours < adjustedEndDayHour) { - const startViewDate = this.getStartViewDate(); - const nextDayDate = new Date(startViewDate); - nextDayDate.setDate(nextDayDate.getDate() + 1); - - const isNextDay = currentDate.getFullYear() === nextDayDate.getFullYear() - && currentDate.getMonth() === nextDayDate.getMonth() - && currentDate.getDate() === nextDayDate.getDate(); - - if (!isNextDay) { - hours = adjustedStartDayHour; - } - } else { + const adjustedStartDayHour = (startDayHour + viewOffsetHours) % 24; + const adjustedEndDayHour = (endDayHour + viewOffsetHours) % 24; + const crossesMidnight = adjustedEndDayHour > 0 && adjustedStartDayHour >= adjustedEndDayHour; + const effectiveEndDayHour = adjustedEndDayHour === 0 ? 24 : adjustedEndDayHour; + + switch (true) { + case crossesMidnight: + // When range crosses midnight: [adjustedStartDayHour, 24) ∪ [0, adjustedEndDayHour) + // Hours in [0, adjustedEndDayHour) are valid only on the next day after startViewDate + if (hours >= adjustedStartDayHour) { + // Hours in first range [adjustedStartDayHour, 24) - keep original hours + } else if (hours < adjustedEndDayHour) { + const startViewDate = this.getStartViewDate(); + const nextDayDate = new Date(startViewDate); + nextDayDate.setDate(nextDayDate.getDate() + 1); + + const isNextDay = currentDate.getFullYear() === nextDayDate.getFullYear() + && currentDate.getMonth() === nextDayDate.getMonth() + && currentDate.getDate() === nextDayDate.getDate(); + + if (!isNextDay) { hours = adjustedStartDayHour; } - break; - - case hours < adjustedStartDayHour: + } else { hours = adjustedStartDayHour; - break; + } + break; - case hours >= effectiveEndDayHour: - hours = effectiveEndDayHour - 1; - break; + case hours < adjustedStartDayHour: + hours = adjustedStartDayHour; + break; - default: - break; - } + case hours >= effectiveEndDayHour: + hours = effectiveEndDayHour - 1; + break; + + default: + break; } currentDate.setHours(hours, minutes, 0, 0); @@ -1912,24 +1902,22 @@ class SchedulerWorkSpace extends Widget { const viewOffset = this.option('viewOffset') as number; const min = this.getStartViewDate(); const max = this.getEndViewDate(); - const startDayHour = this.option('startDayHour'); - const endDayHour = this.option('endDayHour'); - const viewOffsetHours = viewOffset / HOUR_MS; - - const minDate = new Date(min); - minDate.setHours(0, 0, 0, 0); - const maxDate = new Date(max); - maxDate.setHours(23, 59, 59, 999); - const dateOnly = new Date(date); - dateOnly.setHours(0, 0, 0, 0); + // Get the date only without time + const minDate = new Date(min.getFullYear(), min.getMonth(), min.getDate()); + const maxDate = new Date(max.getFullYear(), max.getMonth(), max.getDate()); + const dateOnly = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + // Check if the date is within the min and max dates if (dateOnly >= minDate && dateOnly <= maxDate) { return true; } // When viewOffset causes range to cross midnight, allow the next day after maxDate if (viewOffset !== 0) { + const startDayHour = this.option('startDayHour'); + const endDayHour = this.option('endDayHour'); + const viewOffsetHours = viewOffset / HOUR_MS; const adjustedStartDayHour = (startDayHour + viewOffsetHours) % 24; const adjustedEndDayHour = (endDayHour + viewOffsetHours) % 24; const crossesMidnight = adjustedEndDayHour > 0 && adjustedStartDayHour >= adjustedEndDayHour; @@ -1937,11 +1925,8 @@ class SchedulerWorkSpace extends Widget { if (crossesMidnight) { const nextDayMin = new Date(maxDate); nextDayMin.setDate(nextDayMin.getDate() + 1); - nextDayMin.setHours(0, 0, 0, 0); - const nextDayMax = new Date(nextDayMin); - nextDayMax.setHours(23, 59, 59, 999); - if (dateOnly >= nextDayMin && dateOnly <= nextDayMax) { + if (dateOnly.getTime() === nextDayMin.getTime()) { return true; } } From 82d04c00bed65acf4a3597549cbeb437ed6fd957 Mon Sep 17 00:00:00 2001 From: Sergio Bur Date: Fri, 9 Jan 2026 09:29:07 +0100 Subject: [PATCH 09/10] feat: optimize code --- .../__internal/scheduler/workspaces/m_work_space.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts index 4fc5798949ad..7c4f54548bfc 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts @@ -1421,11 +1421,9 @@ class SchedulerWorkSpace extends Widget { switch (true) { case crossesMidnight: - // When range crosses midnight: [adjustedStartDayHour, 24) ∪ [0, adjustedEndDayHour) - // Hours in [0, adjustedEndDayHour) are valid only on the next day after startViewDate - if (hours >= adjustedStartDayHour) { - // Hours in first range [adjustedStartDayHour, 24) - keep original hours - } else if (hours < adjustedEndDayHour) { + // Range crosses midnight: [adjustedStartDayHour, 24) ∪ [0, adjustedEndDayHour) + if (hours < adjustedEndDayHour) { + // Hours in [0, adjustedEndDayHour) - valid only on next day const startViewDate = this.getStartViewDate(); const nextDayDate = new Date(startViewDate); nextDayDate.setDate(nextDayDate.getDate() + 1); @@ -1437,7 +1435,8 @@ class SchedulerWorkSpace extends Widget { if (!isNextDay) { hours = adjustedStartDayHour; } - } else { + } else if (hours < adjustedStartDayHour) { + // Hours outside valid ranges - normalize to start hours = adjustedStartDayHour; } break; From ead33a02c266faa4359068335e19c4d46d8fb680 Mon Sep 17 00:00:00 2001 From: Sergio Bur Date: Fri, 9 Jan 2026 15:46:44 +0100 Subject: [PATCH 10/10] feat: update fix --- .../scheduler/__tests__/workspace.test.ts | 90 ++++++++++++-- .../scheduler/workspaces/m_work_space.ts | 112 ++++++------------ 2 files changed, 115 insertions(+), 87 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/workspace.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/workspace.test.ts index eeebc35e6c2c..65f1dcc99b0c 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/workspace.test.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/workspace.test.ts @@ -126,6 +126,76 @@ describe('Workspace', () => { scrollBySpy.mockRestore(); }); + it('should scroll to date with offset after midnight: 720 (12 hours)', async () => { + const { scheduler } = await createScheduler({ + views: ['timelineDay'], + currentView: 'timelineDay', + currentDate: new Date(2021, 1, 2), + firstDayOfWeek: 0, + startDayHour: 6, + endDayHour: 18, + offset: 720, + cellDuration: 60, + height: 580, + }); + + const workspace = scheduler.getWorkSpace(); + const scrollable = workspace.getScrollable(); + const $scrollable = scrollable.$element(); + const scrollBySpy = jest.spyOn(scrollable, 'scrollBy'); + + // With offset: 720 (12 hours), cells start at 18:00 (6:00 + 12h) + // For date 3 feb 04:00, this should be cell index 10 (18:00=0, 19:00=1, ... 04:00=10) + const leftCellCount = 10; + const cellWidth = workspace.getCellWidth(); + const scrollableWidth = getWidth($scrollable); + const expectedLeft = leftCellCount * cellWidth - (scrollableWidth - cellWidth) / 2; + + const targetDate = new Date(2021, 1, 3, 4, 0); + scheduler.scrollTo(targetDate, undefined, false); + + expect(scrollBySpy).toHaveBeenCalledTimes(1); + const scrollParams = scrollBySpy.mock.calls[0][0] as { left: number; top: number }; + expect(scrollParams.left).toBeCloseTo(expectedLeft, 1); + + scrollBySpy.mockRestore(); + }); + + it('should scroll to end of day', async () => { + const { scheduler } = await createScheduler({ + views: ['timelineWeek'], + currentView: 'timelineWeek', + currentDate: new Date(2021, 1, 2), + firstDayOfWeek: 0, + startDayHour: 6, + endDayHour: 18, + offset: 120, + cellDuration: 60, + height: 580, + }); + + const workspace = scheduler.getWorkSpace(); + const scrollable = workspace.getScrollable(); + const $scrollable = scrollable.$element(); + const scrollBySpy = jest.spyOn(scrollable, 'scrollBy'); + + // With offset: 720 (12 hours), cells start at 18:00 (6:00 + 12h) + // For date 3 feb 04:00, this should be cell index 3 (18:00=0, ... 22:00=35) + const leftCellCount = 35; + const cellWidth = workspace.getCellWidth(); + const scrollableWidth = getWidth($scrollable); + const expectedLeft = leftCellCount * cellWidth - (scrollableWidth - cellWidth) / 2; + + const targetDate = new Date(2021, 1, 2, 22, 0); + scheduler.scrollTo(targetDate, undefined, false); + + expect(scrollBySpy).toHaveBeenCalledTimes(1); + const scrollParams = scrollBySpy.mock.calls[0][0] as { left: number; top: number }; + expect(scrollParams.left).toBeCloseTo(expectedLeft, 1); + + scrollBySpy.mockRestore(); + }); + describe('hour normalization', () => { it('should normalize hours to visible range without viewOffset', async () => { const { scheduler } = await createScheduler({ @@ -141,16 +211,16 @@ describe('Workspace', () => { const scrollable = workspace.getScrollable(); const scrollBySpy = jest.spyOn(scrollable, 'scrollBy'); - // Below startDayHour (6), should normalize to 6 + // Below startDayHour (6), should NOT normalize to 6 (?) const dateBelowRange = new Date(2021, 1, 2, 4, 0); scheduler.scrollTo(dateBelowRange, undefined, false); - expect(scrollBySpy).toHaveBeenCalled(); + expect(scrollBySpy).not.toHaveBeenCalled(); scrollBySpy.mockClear(); - // Above endDayHour (18), should normalize to 17 + // Above endDayHour (18), should NOT normalize to 17 (?) const dateAboveRange = new Date(2021, 1, 2, 20, 0); scheduler.scrollTo(dateAboveRange, undefined, false); - expect(scrollBySpy).toHaveBeenCalled(); + expect(scrollBySpy).not.toHaveBeenCalled(); scrollBySpy.mockClear(); // Within range [6, 18), should scroll normally @@ -175,10 +245,10 @@ describe('Workspace', () => { const scrollable = workspace.getScrollable(); const scrollBySpy = jest.spyOn(scrollable, 'scrollBy'); - // Below adjustedStartDayHour (12), should normalize to 12 + // Below adjustedStartDayHour (12), should NOT normalize to 12 (?) const dateBelowAdjustedRange = new Date(2021, 1, 2, 10, 0); scheduler.scrollTo(dateBelowAdjustedRange, undefined, false); - expect(scrollBySpy).toHaveBeenCalled(); + expect(scrollBySpy).not.toHaveBeenCalled(); scrollBySpy.mockClear(); // Within adjusted range [12, 24), should scroll normally @@ -203,10 +273,10 @@ describe('Workspace', () => { const scrollable = workspace.getScrollable(); const scrollBySpy = jest.spyOn(scrollable, 'scrollBy'); - // In gap [6, 18), should normalize to 18:00 Feb 2 + // In gap [6, 18), should NOT normalize to 18:00 Feb 2 (?) const dateInGap = new Date(2021, 1, 2, 10, 0); scheduler.scrollTo(dateInGap, undefined, false); - expect(scrollBySpy).toHaveBeenCalled(); + expect(scrollBySpy).not.toHaveBeenCalled(); scrollBySpy.mockClear(); // In range [18, 24) on Feb 2, should scroll normally @@ -215,10 +285,10 @@ describe('Workspace', () => { expect(scrollBySpy).toHaveBeenCalled(); scrollBySpy.mockClear(); - // In range [0, 6) but on wrong day (Feb 2), should normalize to 18:00 Feb 2 + // In range [0, 6) but on wrong day (Feb 2), should NOT normalize to 18:00 Feb 2 (?) const dateInSecondRangeWrongDay = new Date(2021, 1, 2, 3, 0); scheduler.scrollTo(dateInSecondRangeWrongDay, undefined, false); - expect(scrollBySpy).toHaveBeenCalled(); + expect(scrollBySpy).not.toHaveBeenCalled(); scrollBySpy.mockClear(); // In range [0, 6) on correct day (Feb 3), should scroll normally diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts index 7c4f54548bfc..c02df2f016bc 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts @@ -691,8 +691,8 @@ class SchedulerWorkSpace extends Widget { } if (this.isVirtualScrolling() - && (this.virtualScrollingDispatcher.horizontalScrollingAllowed - || this.virtualScrollingDispatcher.height)) { + && (this.virtualScrollingDispatcher.horizontalScrollingAllowed + || this.virtualScrollingDispatcher.height)) { const currentOnScroll = config.onScroll; config = { ...config, @@ -1407,8 +1407,7 @@ class SchedulerWorkSpace extends Widget { return (this.$element() as any).find(`.${GROUP_HEADER_CLASS}`); } - _getScrollCoordinates(hours, minutes, date, groupIndex?: any, allDay?: any) { - const currentDate = date || new Date(this.option('currentDate')); + _normalizeHoursToVisibleRange(hours: number): number { const startDayHour = this.option('startDayHour'); const endDayHour = this.option('endDayHour'); const viewOffset = this.option('viewOffset') as number; @@ -1419,39 +1418,21 @@ class SchedulerWorkSpace extends Widget { const crossesMidnight = adjustedEndDayHour > 0 && adjustedStartDayHour >= adjustedEndDayHour; const effectiveEndDayHour = adjustedEndDayHour === 0 ? 24 : adjustedEndDayHour; - switch (true) { - case crossesMidnight: - // Range crosses midnight: [adjustedStartDayHour, 24) ∪ [0, adjustedEndDayHour) - if (hours < adjustedEndDayHour) { - // Hours in [0, adjustedEndDayHour) - valid only on next day - const startViewDate = this.getStartViewDate(); - const nextDayDate = new Date(startViewDate); - nextDayDate.setDate(nextDayDate.getDate() + 1); - - const isNextDay = currentDate.getFullYear() === nextDayDate.getFullYear() - && currentDate.getMonth() === nextDayDate.getMonth() - && currentDate.getDate() === nextDayDate.getDate(); - - if (!isNextDay) { - hours = adjustedStartDayHour; - } - } else if (hours < adjustedStartDayHour) { - // Hours outside valid ranges - normalize to start - hours = adjustedStartDayHour; - } - break; - - case hours < adjustedStartDayHour: - hours = adjustedStartDayHour; - break; + if (!crossesMidnight) { + if (hours < adjustedStartDayHour) { + return adjustedStartDayHour; + } + if (hours >= effectiveEndDayHour) { + return effectiveEndDayHour - 1; + } + } - case hours >= effectiveEndDayHour: - hours = effectiveEndDayHour - 1; - break; + return hours; + } - default: - break; - } + _getScrollCoordinates(hours, minutes, date, groupIndex?: any, allDay?: any) { + const currentDate = date || new Date(this.option('currentDate')); + hours = this._normalizeHoursToVisibleRange(hours); currentDate.setHours(hours, minutes, 0, 0); @@ -1544,8 +1525,8 @@ class SchedulerWorkSpace extends Widget { isGroupedByDate() { return this.option('groupByDate') - && this._isHorizontalGroupedWorkSpace() - && this._getGroupCount() > 0; + && this._isHorizontalGroupedWorkSpace() + && this._getGroupCount() > 0; } // TODO: refactor current time indicator @@ -1803,9 +1784,9 @@ class SchedulerWorkSpace extends Widget { const cellEndTime = cellEndDate.getTime(); if (((!inAllDayRow && cellStartTime <= time - && time < cellEndTime) - || (inAllDayRow && trimmedTime === cellStartTime)) - && groupIndex === cellGroupIndex) { + && time < cellEndTime) + || (inAllDayRow && trimmedTime === cellStartTime)) + && groupIndex === cellGroupIndex) { return false; } return currentResult; @@ -1846,9 +1827,9 @@ class SchedulerWorkSpace extends Widget { const rowIndex = index / totalColumnCount; if (scrolledColumnCount <= columnIndex - && columnIndex < columnCount - && scrolledRowCount <= rowIndex - && rowIndex < rowCount) { + && columnIndex < columnCount + && scrolledRowCount <= rowIndex + && rowIndex < rowCount) { result.push($cell); } }); @@ -1898,41 +1879,18 @@ class SchedulerWorkSpace extends Widget { } _isValidScrollDate(date, throwWarning = true) { - const viewOffset = this.option('viewOffset') as number; const min = this.getStartViewDate(); const max = this.getEndViewDate(); + const viewOffset = this.option('viewOffset') as number; + const adjustedMin = new Date(min.getTime() + viewOffset); + const adjustedMax = new Date(max.getTime() + viewOffset); - // Get the date only without time - const minDate = new Date(min.getFullYear(), min.getMonth(), min.getDate()); - const maxDate = new Date(max.getFullYear(), max.getMonth(), max.getDate()); - const dateOnly = new Date(date.getFullYear(), date.getMonth(), date.getDate()); - - // Check if the date is within the min and max dates - if (dateOnly >= minDate && dateOnly <= maxDate) { - return true; - } - - // When viewOffset causes range to cross midnight, allow the next day after maxDate - if (viewOffset !== 0) { - const startDayHour = this.option('startDayHour'); - const endDayHour = this.option('endDayHour'); - const viewOffsetHours = viewOffset / HOUR_MS; - const adjustedStartDayHour = (startDayHour + viewOffsetHours) % 24; - const adjustedEndDayHour = (endDayHour + viewOffsetHours) % 24; - const crossesMidnight = adjustedEndDayHour > 0 && adjustedStartDayHour >= adjustedEndDayHour; - - if (crossesMidnight) { - const nextDayMin = new Date(maxDate); - nextDayMin.setDate(nextDayMin.getDate() + 1); - - if (dateOnly.getTime() === nextDayMin.getTime()) { - return true; - } - } + if (date < adjustedMin || date > adjustedMax) { + throwWarning && errors.log('W1008', date); + return false; } - throwWarning && errors.log('W1008', date); - return false; + return true; } needApplyCollectorOffset() { @@ -3419,10 +3377,10 @@ const createDragBehaviorConfig = ( const isCurrentSchedulerElement = dateTables.find(el).length === 1; return isCurrentSchedulerElement - && ( - classList.contains(DATE_TABLE_CELL_CLASS) - || classList.contains(ALL_DAY_TABLE_CELL_CLASS) - ); + && ( + classList.contains(DATE_TABLE_CELL_CLASS) + || classList.contains(ALL_DAY_TABLE_CELL_CLASS) + ); }); if (droppableCell) {